From bc5eadc7ef95ab6152d7e73b20642af1d35eaac6 Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:05:06 +0100 Subject: [PATCH 01/82] Pencil: ignores Smart Guides and Snap to Point The Pencil no longer snaps to Smart Guides or Snap to Point and shows no Smart Guides while hovering or drawing: a stroke's points, its ends included, and both ends of its straight lines are where the pen went. Snap to Grid and Snap to Pixel are separate and still move its ends (#2958 is about the grid). Closing, continuing a selected path and Edit Selected Paths are unchanged; the Knife's straight cuts still snap. --- crates/tools/src/draw2/gesture.rs | 53 +++++++++-------- crates/tools/src/draw2/gesture/tests.rs | 77 +++++++++++++++++++------ docs/mcp.md | 6 +- docs/target-app-parity.md | 2 +- 4 files changed, 91 insertions(+), 47 deletions(-) diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index e4811418c7..aee21e9375 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -6,9 +6,9 @@ //! points with straight segments and the release fits them; Alt on release (or ending near the start) closes the path, and starting //! near an end of a selected open path continues it. Shift held at the press draws a straight //! Pencil line at 45° steps from the Constrain Angle instead, and Alt held at the press (with Alt -//! key toggles to Smooth Tool off) draws a straight line at any angle. The Pencil's new path starts and ends on -//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points -//! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen +//! key toggles to Smooth Tool off) draws a straight line at any angle. The Pencil ignores Smart +//! Guides and Snap to Point: it shows no Smart Guides, hovering or drawing, and its ends follow the +//! hand. Snap to Grid and Snap to Pixel still move its ends (`grid_snaps`). The Paintbrush records the pen pressure of each point (a stroke a pen //! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and //! the pen's tilt and barrel rotation where the pen reads them (`[x, y, pressure, tiltX, tiltY, //! rotation]` points, `null` for what a point lacks) for brushes that vary with them. The Blob @@ -210,27 +210,29 @@ impl GestureTool { } } - /// Do the ends of its strokes snap (the Pencil)? - fn snaps(&self) -> bool { - self.id == "pencil" - } - /// Does a straight Knife cut snap to anchors and paths? Only with Smart Guides on, which Snap to - /// Pixel and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the Pencil's straight - /// line's do: the press, and the release unless Shift keeps it on a 45° step. + /// Pixel and Snap to Grid turn off (`PointSnap::new`). Its ends snap: the press, and the + /// release unless Shift keeps it on a 45° step. fn knife_snaps(&self, cx: &ToolContext) -> bool { self.id == "knife" && self.straight && cx.smart_guides && !cx.snap_to_pixel && !cx.snap_to_grid } - /// Does the end of a straight line snap: the Pencil's Alt line's, or a straight Knife cut's - /// without Shift? + /// Do the Pencil's ends snap? Only to the grid or to pixels, with View → Snap to Grid or Snap + /// to Pixel on: those are separate from Smart Guides (and turn them off), and the grid case is + /// its own question (#2958). Smart Guides and Snap to Point never move them. + fn grid_snaps(&self, cx: &ToolContext) -> bool { + self.id == "pencil" && (cx.snap_to_grid || cx.snap_to_pixel) + } + + /// Does the end of a straight line without Shift snap: the Pencil's Alt line's to the grid or + /// pixels, a straight Knife cut's to anchors and paths? fn line_end_snaps(&self, cx: &ToolContext) -> bool { - self.straight && self.unconstrained && (self.snaps() || self.knife_snaps(cx)) + self.straight && self.unconstrained && (self.grid_snaps(cx) || self.knife_snaps(cx)) } /// Where a stroke or straight line released at `p` ends: where it snaps when its end snaps. fn release_end(&mut self, cx: &ToolContext, p: Point) -> Point { - if (self.snaps() && !self.smoothing && !self.straight) || self.line_end_snaps(cx) { self.snap.drag(cx, p, None) } else { p } + if (self.grid_snaps(cx) && !self.smoothing && !self.straight) || self.line_end_snaps(cx) { self.snap.drag(cx, p, None) } else { p } } /// Is a straight line with its end at `end` back at its press point (within a pixel), so that @@ -555,10 +557,6 @@ impl Tool for GestureTool { self.tip_at = (!ev.mods.cmd && !self.blob_alt_smooths(ev.mods)).then_some(p); } match ev.kind { - PointerKind::Move if self.snaps() => { - self.snap.hover(cx, p, &[], None); - vec![] - } PointerKind::Down => { self.active = true; self.began = false; @@ -581,11 +579,12 @@ impl Tool for GestureTool { _ => vec![], }; self.redraw = if self.draws_path() && !self.smoothing && self.extend.is_none() { self.find_redraw(cx, p) } else { None }; - // A path continued starts at its end, one redrawn on it; a new one where it snaps. - // A straight line is constrained from the end it continues, so it starts there. + // A path continued starts at its end, one redrawn on it; a new one at the press, + // or on the grid or a pixel when they snap. A straight line is constrained from the + // end it continues, so it starts there. let start = if let Some((_, _, end)) = extend.filter(|_| self.straight) { end - } else if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() { + } else if self.grid_snaps(cx) && !self.smoothing && self.extend.is_none() && self.redraw.is_none() { self.snap.press(cx, p, &[], None) } else if self.knife_snaps(cx) { // A straight Knife cut, its overlay and Shift's 45° steps start from the snapped point. @@ -609,13 +608,13 @@ impl Tool for GestureTool { self.moved |= self.points.first().is_some_and(|start| p.distance(*start) > vectorcraft_geom::EPS); let p = self.constrained(cx, p, ev.mods); let mut end = p; - if self.snaps() && !self.smoothing && !self.straight { - // Where the stroke would end: shown, taken on release. + if self.grid_snaps(cx) && !self.smoothing && !self.straight { + // Where the stroke would end on the grid or a pixel: taken on release. end = self.snap.drag(cx, p, None); } let p = if self.line_end_snaps(cx) { - // Alt's line ends where a stroke would, and a Knife cut's on the anchors and - // paths its press snaps to: shown, taken on release. + // Alt's line ends on the grid or a pixel when they snap, and a Knife cut on the + // anchors and paths its press snaps to: shown, taken on release. self.snap.drag(cx, p, None) } else { p @@ -635,7 +634,7 @@ impl Tool for GestureTool { } else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) || self.points.len() >= crate::MAX_STROKE_SAMPLES { // A release here keeps the last point unless it snaps (`Up`), so the ring // follows the point `finish` will test. - let kept = self.points.last().copied().filter(|l| l.distance(p) < cx.tol(1.0) && !self.snaps()); + let kept = self.points.last().copied().filter(|l| l.distance(p) < cx.tol(1.0) && !self.grid_snaps(cx)); let release_end = kept.unwrap_or(end); self.near_start = self.release_closes_near_start(cx, release_end); return vec![]; @@ -690,7 +689,7 @@ impl Tool for GestureTool { } } else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) { self.push(end, ev); - } else if self.snaps() + } else if self.grid_snaps(cx) && !self.smoothing && self.points.len() > 1 && let Some(last) = self.points.last_mut() diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index a0fe487289..b1875d56d4 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -22,25 +22,67 @@ fn drag_held(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) v } -/// The Pencil's new path starts and ends on Smart Guides (#506), the points between where -/// the hand went. +/// The Pencil follows the hand from the press to the release: it ignores Smart Guides and Snap to Point and shows no +/// Smart Guides, hovering or drawing over the art, with Smart Guides on or with only Snap to Point. #[test] -fn pencil_ends_snap_to_smart_guides() { +fn pencil_follows_the_hand_and_shows_no_smart_guides() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("pencil"); - let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]])); + let on = cx(&d, &s, &p); + let snap_to_point = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + // Near the corners at (100, 100) and (200, 200), within Snap to Point's 2 px of them. + let hand = [(101.0, 99.0), (151.0, 300.0), (250.0, 320.0), (199.0, 201.0)]; + for cx in [&on, &snap_to_point] { + let mut t = GestureTool::new("pencil"); + t.pointer(cx, &PointerEvent::new(PointerKind::Move, hand[0].0, hand[0].1)); + assert!(t.overlays(cx).is_empty(), "hovering: {:?}", t.overlays(cx)); + t.pointer(cx, &PointerEvent::new(PointerKind::Down, hand[0].0, hand[0].1)); + for &(x, y) in &hand[1..] { + t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y)); + assert!(t.snap.guides().is_empty(), "drawing: {:?}", t.snap.guides()); + } + let last = hand[hand.len() - 1]; + let acts = t.pointer(cx, &PointerEvent::new(PointerKind::Up, last.0, last.1)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!(hand.map(|(x, y)| [x, y]))); + } // The Paintbrush follows the hand all the way. let mut t = GestureTool::new("paintbrush"); - let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default()); + let acts = drag(&mut t, &on, &[(102.0, 98.0), (151.0, 300.0)], Mods::default()); let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; assert_eq!(v["points"][0], json!([102.0, 98.0])); } +/// Snap to Grid and Snap to Pixel are not Smart Guides: they still move the Pencil's ends (and an +/// Alt line's), while the points between follow the hand. +#[test] +fn pencil_ends_still_snap_to_the_grid_and_pixels() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let grid = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let pixel = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) }; + let on = |cx: &ToolContext, q: (f64, f64)| match cx.snap_to_grid { + true => vectorcraft_geom::snap::snap_point_to_grid(Point::new(q.0, q.1), cx.grid_step()), + false => Point::new(q.0.round(), q.1.round()), + }; + let hand = [(301.3, 302.6), (351.4, 420.2), (447.7, 452.9)]; + for cx in [&grid, &pixel] { + let (first, last) = (on(cx, hand[0]), on(cx, hand[2])); + assert!(first != Point::new(hand[0].0, hand[0].1) && last != Point::new(hand[2].0, hand[2].1)); + let mut t = GestureTool::new("pencil"); + let acts = drag(&mut t, cx, &hand, Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[first.x, first.y], [351.4, 420.2], [last.x, last.y]])); + // Alt's straight line ends on the grid or a pixel too. + let mut t = GestureTool::new("pencil"); + let acts = drag_held(&mut t, cx, &[hand[0], hand[2]], Mods { alt: true, ..Mods::default() }); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["points"][1], json!([last.x, last.y])); + } +} + /// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and /// the Pencil send plain points. #[test] @@ -639,10 +681,10 @@ fn pencil_shift_draws_a_straight_line_at_45_degree_steps() { } } -/// #3799: a Shift line leaves no Smart Guides of its press on screen while it is dragged -/// (its end doesn't snap), and Alt at its release leaves it open. +/// #3799: a Shift line shows no Smart Guides, at its press or while it is dragged, and Alt at its +/// release leaves it open. #[test] -fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() { +fn pencil_shift_line_shows_no_guides_and_stays_open_with_alt() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); @@ -650,7 +692,7 @@ fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() { let shift = Mods { shift: true, ..Default::default() }; let mut t = GestureTool::new("pencil"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 102.0, 98.0).with_mods(shift)); - assert!(!t.overlays(&cx).is_empty(), "the press snaps to the corner and shows it"); + assert!(t.snap.guides().is_empty(), "the press: {:?}", t.snap.guides()); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 140.0).with_mods(shift)); assert!(t.overlays(&cx).is_empty(), "{:?}", t.overlays(&cx)); let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; @@ -1094,10 +1136,11 @@ fn paintbrush_alt_with_only_two_anchor_line_selected_draws_a_closed_stroke() { assert!(matches!(acts.last().and_then(|a| a.get(1)), Some(Action::Commit)), "{acts:?}"); } -/// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of -/// a selected open path it continues the path from that end to the pointer. +/// #3799: Alt's line runs from the press to the pointer as a stroke does, snapping to nothing, +/// and pressed on an end of a selected open path it continues the path from that end to the +/// pointer. #[test] -fn pencil_alt_line_snaps_its_ends_and_continues_a_selected_path() { +fn pencil_alt_line_follows_the_hand_and_continues_a_selected_path() { let (mut d, _) = doc_with_rect(); let alt = Mods { alt: true, ..Default::default() }; let p = paint(); @@ -1107,7 +1150,7 @@ fn pencil_alt_line_snaps_its_ends_and_continues_a_selected_path() { let mut t = GestureTool::new("pencil"); let acts = drag_held(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (198.0, 203.0)], alt); let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["points"], json!([[100.0, 100.0], [200.0, 200.0]])); + assert_eq!(v["points"], json!([[102.0, 98.0], [198.0, 203.0]])); } let l = d.layers[0].id; let id = d.alloc_id(); diff --git a/docs/mcp.md b/docs/mcp.md index 89c7b1f683..b31c124b77 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4812,9 +4812,11 @@ snap the pointer as drawing tools do, so dragging a corner onto another object's ``` Every point a drawing tool places snaps the same way, through one snapper (#506): the Pen's and Curvature tool's -anchors, the Pencil's first and last point, the shape tools' start and dragged corner or radius (`rectangle`, +anchors, the shape tools' start and dragged corner or radius (`rectangle`, `roundedRectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, `arc`, `spiral`, `rectangularGrid`, `polarGrid`), the -Type tool's click and frame, and a new artboard's corners. The point lands on another object's anchor, centre or +Type tool's click and frame, and a new artboard's corners. The Pencil ignores Smart Guides and Snap to Point and +shows no Smart Guides: its strokes and straight lines follow the hand (Snap to Grid and Snap to Pixel still move +their ends). The point lands on another object's anchor, centre or path ("anchor", "center", "path"), else lines up with the anchors, edges and centres of the art and the artboards (a line from the target and "align"); the Pen's lands on the path being drawn too, so a click on its first anchor closes it exactly. With `mods.shift` the point keeps to its 45° step from the anchor before it (from `constrainAngle`; a box diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a305e9d29d..fd496a7a5f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -941,7 +941,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 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). 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, 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. - **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`). 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). From 5e19c59f33316f3f2b4d3ebc56bfc061cfea820a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:14:58 -0800 Subject: [PATCH 02/82] Automation: one classification of file and folder params A param key names a file or a folder by a reviewed list of whole names, read in the context of the command it is for (ParamsPlace): the plug-in call check, the control server's reply hiding and recorded-step filtering, and the sweeps' temp-folder redirection all use it. - `url` (a web link) and `folders` (an export's grouping) no longer count as files or folders, and keys that only end like one (alignToPath, wholePaths, sortPaths, linksFolder and others) are no longer path params in control replies or recorded steps. - `dir`, `directory` and the folder preferences that prefs.set takes in `values` (fontsFolder, pluginsFolder, recoveryFolder, templatesFolder) are folders everywhere, so a relative path under one outside the read root is taken out of a recorded step. - A `command.batch` step, an `engine.execute` request and a recorded step keep their own command's exceptions below the top level, so the sweeps no longer replace a nested web link or export grouping. - A new test sorts every catalog key that reads like a file or folder key into the reviewed lists and checks every classifier against them. --- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/params.rs | 2 +- crates/engine/src/cmd/plugin_call.rs | 188 ++++++++++++++++++++++----- crates/engine/src/file_access.rs | 8 +- crates/engine/tests/command_sweep.rs | 141 +++++++++++++++++--- crates/testkit/src/strategies.rs | 128 ++++++++++++------ crates/ui-egui/src/control.rs | 102 +++++++++------ crates/ui-egui/src/control/tests.rs | 63 ++++++++- 8 files changed, 498 insertions(+), 136 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index af768284b0..e75c7656de 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -784,7 +784,7 @@ pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; -pub use plugin_call::{Location, key_location, location_in, location_keys}; +pub use plugin_call::{Location, ParamsPlace, key_location, location_in, location_keys}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index c38854ae76..40e097e205 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -174,7 +174,7 @@ fn accepted(doc: &'static str, depth: usize) -> Option> { /// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands /// its `…` entries name)? -pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool { +pub fn takes_key(doc: &'static str, key: &str) -> bool { doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key)) } diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index 71fbcc8540..7001cf2ebd 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -101,12 +101,30 @@ const FILE_WORDS: &[&str] = &[ "clipboard", ]; -/// Param keys that name a file or a folder. -const FILE_KEYS: &[&str] = - &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url", "diffPath"]; +/// Param keys that name a file or a folder, each by its whole name, the folder preferences +/// `prefs.set {values}` takes included: a command that adds a key naming one adds it here. A key +/// that only ends like one (`alignToPath`, `linksFolder`, `sortPaths`) names none, nor do `folders` +/// (how an export groups its files) and `url` (a web link). +const FILE_KEYS: &[&str] = &[ + "path", + "paths", + "file", + "files", + "folder", + "dir", + "directory", + "destinationFolder", + "sourceFolder", + "diffPath", + "fontsFolder", + "pluginsFolder", + "recoveryFolder", + "templatesFolder", +]; /// The keys in [`FILE_KEYS`] that name a folder. -const FOLDER_KEYS: &[&str] = &["folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder"]; +const FOLDER_KEYS: &[&str] = + &["folder", "dir", "directory", "destinationFolder", "sourceFolder", "fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"]; /// What a param key that names a place on disk names there. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -132,7 +150,83 @@ pub fn key_location(key: &str) -> Option { /// What `key` names on disk in command `spec`: [`key_location`], or `None` when [`NOT_FILES`] /// names it for `spec` (it names part of the document there). pub fn location_in(spec: &CommandSpec, key: &str) -> Option { - if NOT_FILES.contains(&(spec.id, key)) { None } else { key_location(key) } + ParamsPlace::top(Some(spec)).member(key, None).1 +} + +/// A place in the params of a command. What a key names on disk depends on the command and on the +/// keys above it: [`key_location`], less a key [`NOT_FILES`] names for the command at the top of its +/// params and one at the end of a [`NESTED_NOT_FILES`] key list. A walk of params starts at +/// [`ParamsPlace::top`] and goes down with [`ParamsPlace::member`] and [`ParamsPlace::item`]. +#[derive(Clone, Copy, Debug)] +pub struct ParamsPlace { + /// The command whose params these are; `None` for a command that isn't known. + command: Option<&'static str>, + /// Whether this is the top of the params. + top: bool, + /// The keys of a [`NESTED_NOT_FILES`] list still to follow below this place. + trail: Option<&'static [&'static str]>, +} + +impl ParamsPlace { + /// The top of the params of `spec`, or of a command that isn't known (`None`), where every key in + /// [`FILE_KEYS`] names a file or a folder. + pub fn top(spec: Option<&CommandSpec>) -> ParamsPlace { + ParamsPlace { command: spec.map(|s| s.id), top: true, trail: None } + } + + /// The top of the params of the command with id `id` ([`ParamsPlace::top`]). + pub fn of(id: &str) -> ParamsPlace { + ParamsPlace::top(super::find_command(id)) + } + + /// The top of the params of the command that object `fields` calls, when it is a call + /// (`{command | id: , params}`, as a `command.batch` step or a control request is): + /// the place of its `params` member ([`ParamsPlace::member`]). + pub fn carried(fields: &serde_json::Map) -> Option { + if !fields.contains_key("params") { + return None; + } + let id = fields.get("command").or_else(|| fields.get("id")).and_then(Value::as_str)?; + super::find_command(id).map(|spec| ParamsPlace::top(Some(spec))) + } + + /// The top of the params of the command that array `items` records, when it is a recorded step + /// (`[command id, params]`): the place of its item 1 ([`ParamsPlace::item`]). + pub fn step(items: &[Value]) -> Option { + match items { + [Value::String(id), Value::Object(_)] => super::find_command(id).map(|spec| ParamsPlace::top(Some(spec))), + _ => None, + } + } + + /// The place of member `key` of an object here, with what `key` names on disk there. `carried` is + /// [`ParamsPlace::carried`] of that object: its `params` member is then the top of that command's + /// params, and names no file. + pub fn member(self, key: &str, carried: Option) -> (ParamsPlace, Option) { + if key == "params" + && let Some(inner) = carried + { + return (inner, None); + } + let rest = if self.top { + let id = self.command; + NESTED_NOT_FILES.iter().find(|(c, keys)| Some(*c) == id && keys.first() == Some(&key)).map(|(_, keys)| keys.get(1..).unwrap_or_default()) + } else { + self.trail.filter(|keys| keys.first() == Some(&key)).map(|keys| keys.get(1..).unwrap_or_default()) + }; + let document = (self.top && self.command.is_some_and(|id| NOT_FILES.contains(&(id, key)))) || rest.is_some_and(<[&str]>::is_empty); + let place = ParamsPlace { command: self.command, top: false, trail: rest.filter(|keys| !keys.is_empty()) }; + (place, if document { None } else { key_location(key) }) + } + + /// The place of item `index` of an array here. `step` is [`ParamsPlace::step`] of that array: its + /// item 1 is then the top of that command's params. + pub fn item(self, index: usize, step: Option) -> ParamsPlace { + match step { + Some(inner) if index == 1 => inner, + _ => ParamsPlace { command: self.command, top: false, trail: None }, + } + } } /// The param keys of `spec` that name a file or a folder, with what they name: the keys in @@ -162,11 +256,8 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[ ("symbol.instance.setAppearance", "path"), ("symbol.instance.selectPart", "path"), ("object.objectsOnPath.attach", "path"), - ("attributes.set", "url"), ("svgInteractivity.info", "files"), ("addon.cleanup.rejoin", "paths"), - ("object.slice.options", "url"), - ("document.exportForScreens", "folders"), ]; /// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there, @@ -234,34 +325,24 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool { }) } -/// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth -/// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`] -/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for -/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the -/// thread's. +/// Does `args`, the arguments of a call of command `id`, carry a key that names a file or a folder at +/// any depth (in objects and in arrays, [`ParamsPlace`]), such as an effect's `textureImage.path`? +/// Only a key [`NOT_FILES`] names for `id`, at the top level, and one at the end of a +/// [`NESTED_NOT_FILES`] key list for `id`, aren't counted. A call or a recorded step inside `args` +/// is read as part of `id`'s arguments, not as the command it names. The walk keeps its own stack, +/// so deep arguments can't overflow the thread's. fn names_file(id: &str, args: &Value) -> bool { - // Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list - // still to follow below it. - let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)]; - while let Some((v, top, trail)) = stack.pop() { + let mut stack: Vec<(&Value, ParamsPlace)> = vec![(args, ParamsPlace::of(id))]; + while let Some((v, place)) = stack.pop() { match v { - Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))), + Value::Array(a) => stack.extend(a.iter().enumerate().map(|(i, x)| (x, place.item(i, None)))), Value::Object(o) => { for (k, x) in o { - let k = k.as_str(); - let rest = if top { - NESTED_NOT_FILES - .iter() - .find(|(c, keys)| *c == id && keys.first() == Some(&k)) - .map(|(_, keys)| keys.get(1..).unwrap_or_default()) - } else { - trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default()) - }; - let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty); - if FILE_KEYS.contains(&k) && !document { + let (below, location) = place.member(k, None); + if location.is_some() { return true; } - stack.push((x, false, rest.filter(|keys| !keys.is_empty()))); + stack.push((x, below)); } } _ => {} @@ -595,7 +676,13 @@ mod tests { assert!(names_file("effect.apply", &texture)); assert!(names_file("effect.setParams", &texture)); assert!(names_file("effect.apply", &json!({"params": {"layers": [{"name": "a"}, {"folder": "/tmp"}]}}))); - assert!(names_file("select.all", &json!([[[{"url": "file:///etc/hosts"}]]]))); + assert!(names_file("select.all", &json!([[[{"file": "file:///etc/hosts"}]]]))); + // A web link and an export's grouping name no file. + assert!(!names_file("attributes.set", &json!({"url": "https://example.com/a"}))); + assert!(!names_file("object.move", &json!({"opts": {"folders": "rows", "alignToPath": "baseline"}}))); + // A call nested in the arguments doesn't vouch for its own keys. + assert!(names_file("object.move", &json!({"opts": {"command": "object.setLiveShape", "params": {"path": "/tmp/x"}}}))); + assert!(names_file("object.move", &json!({"opts": ["object.setLiveShape", {"path": "/tmp/x"}]}))); assert!(!names_file("effect.apply", &json!({"effect": "distort.glass", "params": {"distortion": 5, "textureImage": "frosted"}}))); assert!(!names_file("object.move", &json!({"dx": 5, "dy": 0, "ids": [1, 2], "opts": {"copy": false}}))); // `path` is a shape's path in object.setLiveShape, but only as its own param. @@ -670,11 +757,48 @@ mod tests { assert!(found.iter().all(|(k, l)| (*l == Location::Folder) == FOLDER_KEYS.contains(k)), "{}: {found:?}", spec.id); assert!(denied(spec).is_some() || found.is_empty(), "{} names {found:?} but a plug-in may call it", spec.id); } - assert_eq!(key_location("url"), Some(Location::File)); + assert_eq!(key_location("url"), None); + assert_eq!(key_location("folders"), None); + assert_eq!(key_location("linksFolder"), None); assert_eq!(key_location("sourceFolder"), Some(Location::Folder)); assert_eq!(key_location("pathData"), None); } + /// A key keeps the meaning its command gives it at every depth: a call (`{command, params}`) and a + /// recorded step (`[command id, params]`) carry their command's exceptions down, and a custom + /// arrowhead's outline is part of the document only at its own place in `stroke.set`. + #[test] + fn a_params_place_follows_its_command() { + use serde_json::json; + let at = |place: ParamsPlace, keys: &[&str]| { + let mut place = place; + let mut location = None; + for k in keys { + (place, location) = place.member(k, None); + } + location + }; + assert_eq!(at(ParamsPlace::of("object.setLiveShape"), &["path"]), None); + assert_eq!(at(ParamsPlace::of("object.setLiveShape"), &["options", "path"]), Some(Location::File)); + assert_eq!(at(ParamsPlace::of("stroke.set"), &["endArrow", "Custom", "path"]), None); + assert_eq!(at(ParamsPlace::of("stroke.set"), &["endArrow", "Custom", "file"]), Some(Location::File)); + assert_eq!(at(ParamsPlace::of("no.such.command"), &["path"]), Some(Location::File)); + assert_eq!(at(ParamsPlace::top(None), &["destinationFolder"]), Some(Location::Folder)); + let call = json!({"command": "object.setLiveShape", "params": {"path": [[0, 0]]}}); + let carried = call.as_object().and_then(ParamsPlace::carried); + let (inner, location) = ParamsPlace::of("command.batch").member("params", carried); + assert_eq!(location, None); + assert_eq!(inner.member("path", None).1, None); + assert_eq!(ParamsPlace::carried(&serde_json::Map::new()).map(|_| ()), None); + let unknown = json!({"command": "no.such.command", "params": {}}); + assert!(unknown.as_object().and_then(ParamsPlace::carried).is_none()); + let step = [json!("object.setLiveShape"), json!({"path": [[0, 0]]})]; + let place = ParamsPlace::top(None).item(1, ParamsPlace::step(&step)); + assert_eq!(place.member("path", None).1, None); + assert_eq!(ParamsPlace::top(None).item(0, ParamsPlace::step(&step)).member("path", None).1, Some(Location::File)); + assert!(ParamsPlace::step(&[json!("object.setLiveShape")]).is_none()); + } + #[test] fn keys_are_found_as_whole_keys() { assert!(names_key("{path: a .wasm file}", "path")); diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 6ca724d810..9f676e737c 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -222,11 +222,11 @@ pub fn written_elsewhere(path: &str) -> bool { !cfg!(windows) && foreign && current().is_some() } -/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, -/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, -/// `destinationFolder`)? +/// Does a command param named `key` hold a file or folder path in a command that gives it no other +/// meaning ([`crate::cmd::key_location`]: `path`, `file`, `diffPath`, `destinationFolder` and the +/// like)? [`crate::cmd::ParamsPlace`] tells what it holds in a given command. pub fn is_path_key(key: &str) -> bool { - matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end)) + crate::cmd::key_location(key).is_some() } /// May `path` be written here? Always, without roots in force. diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 6577754de0..0cc67e074c 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -5,6 +5,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; +use vectorcraft_engine::cmd::{Location, ParamsPlace}; use vectorcraft_engine::{Session, command_specs}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::Fixture; @@ -271,44 +272,46 @@ fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec) { +/// The strings in `v`, the value at `at` (found at `place`) in a case of command `id`, that name a +/// place on disk outside `dir`, as `id at: value`: `v` itself and the members of arrays in it when +/// `named` (it is under a key that names a file or folder), and in objects at any depth, the values +/// under such keys as the command reads them (`vectorcraft_engine::cmd::ParamsPlace`). +fn outside_locations( + id: &str, + at: &str, + v: &Value, + place: ParamsPlace, + named: bool, + dir: &std::path::Path, + out: &mut std::collections::BTreeSet, +) { match v { Value::String(s) if named && !std::path::Path::new(s).starts_with(dir) => { out.insert(format!("{id} {at}: {v}")); } Value::Array(a) => { + let step = ParamsPlace::step(a); for (i, x) in a.iter().enumerate() { - outside_locations(id, &format!("{at}[{i}]"), x, named, dir, out); + outside_locations(id, &format!("{at}[{i}]"), x, place.item(i, step), named, dir, out); } } Value::Object(o) => { + let carried = ParamsPlace::carried(o); for (k, x) in o { - outside_locations(id, &format!("{at}.{k}"), x, names_location(None, k), dir, out); + let (below, location) = place.member(k, carried); + outside_locations(id, &format!("{at}.{k}"), x, below, location.is_some(), dir, out); } } _ => {} } } -/// Does `key` name a file or folder, in command `spec` when given: a key in -/// `vectorcraft_engine::cmd::key_location`'s list that `vectorcraft_engine::cmd::location_in` keeps -/// for `spec`. -fn names_location(spec: Option<&vectorcraft_engine::CommandSpec>, key: &str) -> bool { - match spec { - Some(spec) => vectorcraft_engine::cmd::location_in(spec, key).is_some(), - None => vectorcraft_engine::cmd::key_location(key).is_some(), - } -} - /// Every string the sweeps send under a key that names a file or folder, in the command catalog /// (`vectorcraft_engine::cmd::location_keys`, so a command added later is checked too) or inside /// an object or an array at any depth, is inside the sweep's temp folder, so no run reads or /// writes in the working directory. `every_command_with_junk_params` sends every such key of every -/// command alone in at least one case ([`junk_cases`]). +/// command alone in at least one case ([`junk_cases`]). Which catalog keys name a file or folder is +/// checked on its own by [`catalog_path_keys_match_location_policy`]. #[test] fn sweep_cases_name_files_only_inside_the_temp_folder() { let path = safe_path(); @@ -329,7 +332,8 @@ fn sweep_cases_name_files_only_inside_the_temp_folder() { for p in &cases { let Some(o) = p.as_object() else { continue }; for (k, v) in o { - outside_locations(c.id, k, v, names_location(Some(c), k), &dir, &mut outside); + let (below, location) = ParamsPlace::top(Some(c)).member(k, None); + outside_locations(c.id, k, v, below, location.is_some(), &dir, &mut outside); } } } @@ -338,6 +342,105 @@ fn sweep_cases_name_files_only_inside_the_temp_folder() { assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n")); } +/// The param keys that name a file or a folder, and which. +const LOCATION_KEYS: &[(&str, Location)] = &[ + ("path", Location::File), + ("paths", Location::File), + ("file", Location::File), + ("files", Location::File), + ("diffPath", Location::File), + ("folder", Location::Folder), + ("dir", Location::Folder), + ("directory", Location::Folder), + ("destinationFolder", Location::Folder), + ("sourceFolder", Location::Folder), + ("fontsFolder", Location::Folder), + ("pluginsFolder", Location::Folder), + ("recoveryFolder", Location::Folder), + ("templatesFolder", Location::Folder), +]; + +/// Param keys that read like a file or folder key ([`reads_like_a_location`]) and name neither: +/// web links, an export's grouping of its files, and choices and switches. +const NOT_LOCATIONS: &[&str] = &[ + "url", + "copyrightUrl", + "folders", + "alignToPath", + "wholePaths", + "emptyTextPaths", + "sortPaths", + "alterPaths", + "includeLinkedFiles", + "linksFolder", +]; + +/// Commands and the key in [`LOCATION_KEYS`] that, at the top of their params, names part of the +/// document (a path of its art, a result field), not a file. +const DOCUMENT_PARTS: &[(&str, &str)] = &[ + ("object.setLiveShape", "path"), + ("path.reverse", "paths"), + ("object.pathfinder.options", "paths"), + ("object.maskImage", "path"), + ("text.createInPath", "path"), + ("imageTrace.make", "paths"), + ("symbol.instance.setAppearance", "path"), + ("symbol.instance.selectPart", "path"), + ("object.objectsOnPath.attach", "path"), + ("svgInteractivity.info", "files"), + ("addon.cleanup.rejoin", "paths"), +]; + +/// Does param key `k` read like one that names a file, a folder or a link: a bare name for one, or a +/// name that ends like one? +fn reads_like_a_location(k: &str) -> bool { + const ENDS: &[&str] = &["Path", "Paths", "File", "Files", "Folder", "Folders", "Dir", "Directory", "Url", "URL"]; + matches!(k, "path" | "paths" | "file" | "files" | "folder" | "folders" | "dir" | "directory" | "url") || ENDS.iter().any(|end| k.ends_with(end)) +} + +/// Every key of every command that reads like a file or folder key (in its params description, the +/// commands its `…` entries name and its schema) is in [`LOCATION_KEYS`] or [`NOT_LOCATIONS`], so a +/// command that adds one fails here until it is sorted. The generic classifications +/// (`vectorcraft_engine::cmd::key_location`, `vectorcraft_engine::file_access::is_path_key`) agree +/// with those lists, and each command reads such a key as they say (`location_in`), less +/// [`DOCUMENT_PARTS`]. +#[test] +fn catalog_path_keys_match_location_policy() { + use vectorcraft_engine::cmd::{key_location, location_in, params::takes_key}; + let catalog: Vec<(&vectorcraft_engine::CommandSpec, Vec)> = command_specs().iter().map(|c| (c, command_param_keys(c))).collect(); + let mut keys: std::collections::BTreeSet = + catalog.iter().flat_map(|(_, keys)| keys.iter().filter(|k| reads_like_a_location(k)).cloned()).collect(); + keys.extend(LOCATION_KEYS.iter().map(|(k, _)| k.to_string()).chain(NOT_LOCATIONS.iter().map(|k| k.to_string()))); + let mut wrong = vec![]; + for k in &keys { + let want = LOCATION_KEYS.iter().find(|(l, _)| l == k).map(|(_, l)| *l); + if want.is_none() && !NOT_LOCATIONS.contains(&k.as_str()) { + wrong.push(format!("`{k}` is in neither LOCATION_KEYS nor NOT_LOCATIONS")); + continue; + } + if key_location(k) != want { + wrong.push(format!("key_location(`{k}`) is {:?}, not {want:?}", key_location(k))); + } + if vectorcraft_engine::file_access::is_path_key(k) != want.is_some() { + wrong.push(format!("is_path_key(`{k}`) is {}", want.is_none())); + } + for (c, named) in &catalog { + if !named.contains(k) && !(c.params.contains('…') && takes_key(c.params, k)) { + continue; + } + let here = if DOCUMENT_PARTS.contains(&(c.id, k.as_str())) { None } else { want }; + if location_in(c, k) != here { + wrong.push(format!("{}: location_in(`{k}`) is {:?}, not {here:?}", c.id, location_in(c, k))); + } + } + } + for (id, key) in DOCUMENT_PARTS { + let named = catalog.iter().any(|(c, _)| c.id == *id && c.params.contains(key)); + assert!(named, "{id} doesn't name `{key}`"); + } + assert!(wrong.is_empty(), "{} keys are classified unlike the reviewed lists:\n{}", wrong.len(), wrong.join("\n")); +} + /// The cases sweep keys the command catalog names as files and folders: `document.open`'s /// `{path | name …}`, `addon.document.saveTimestampedCopy`'s `{folder? (…)}`, `document.compare`'s /// `diffPath`, `file.saveAs`'s `path` after `{…as document.save}` and `links.relink`'s diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 153ede8f02..ac1c380cc3 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -2,7 +2,7 @@ use proptest::prelude::*; use serde_json::{Value, json}; -use vectorcraft_engine::cmd::{Location, key_location, location_in, location_keys}; +use vectorcraft_engine::cmd::{Location, ParamsPlace, key_location, location_keys}; use vectorcraft_engine::{CommandSpec, Session, command_specs}; use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath}; @@ -518,20 +518,8 @@ pub fn command_param_keys(spec: &CommandSpec) -> Vec { keys } -/// Parameter-name keys that name a file or folder in some command ([`key_location`]): fuzzing these -/// with arbitrary strings could write files anywhere, so callers substitute a temp path. -pub fn is_path_key(k: &str) -> bool { - key_location(k).is_some() -} - -/// Path keys that name a folder a command reads, creates or writes files into ([`key_location`]): -/// `folder`, `folders`, `dir`, `directory`, `destinationFolder` and `sourceFolder`. -pub fn is_folder_key(k: &str) -> bool { - key_location(k) == Some(Location::Folder) -} - -/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it (in -/// the same temp folder) for a folder key. +/// The location to send under the path key `k` ([`key_location`]): `safe_path` for a file, a folder +/// next to it (in the same temp folder) for a folder key. pub fn safe_location(k: &str, safe_path: &str) -> String { location(key_location(k).unwrap_or(Location::File), safe_path) } @@ -542,55 +530,74 @@ fn location(l: Location, safe_path: &str) -> String { if l == Location::Folder { format!("{safe_path}-folder") } else { safe_path.to_string() } } -/// `v` with every string in it that can name a place on disk sent to the temp location: `v` -/// itself and the members of arrays in it, at any depth, when `kind` is `Some`, and in an object -/// at any depth, the strings under a path key ([`is_path_key`]). Other values keep their junk -/// shape. -fn redirect(kind: Option, v: &Value, safe_path: &str) -> Value { +/// `v`, found at `place` in a command's params, with every string in it that can name a place on +/// disk sent to the temp location: `v` itself and the members of arrays in it, at any depth, when +/// `kind` is `Some`, and in an object at any depth, the strings under a key that names a file or a +/// folder there ([`ParamsPlace::member`]: a call or a recorded step inside `v` keeps its command's +/// meaning). Other values keep their junk shape. +fn redirect(place: ParamsPlace, kind: Option, v: &Value, safe_path: &str) -> Value { match v { Value::String(_) => kind.map_or_else(|| v.clone(), |l| json!(location(l, safe_path))), - Value::Array(a) => Value::Array(a.iter().map(|x| redirect(kind, x, safe_path)).collect()), - Value::Object(o) => Value::Object(o.iter().map(|(k, x)| (k.clone(), redirect(key_location(k), x, safe_path))).collect()), + Value::Array(a) => { + let step = ParamsPlace::step(a); + Value::Array(a.iter().enumerate().map(|(i, x)| redirect(place.item(i, step), kind, x, safe_path)).collect()) + } + Value::Object(o) => { + let carried = ParamsPlace::carried(o); + let member = |k: &String, x: &Value| { + let (below, l) = place.member(k, carried); + (k.clone(), redirect(below, l, x, safe_path)) + }; + Value::Object(o.iter().map(|(k, x)| member(k, x)).collect()) + } _ => v.clone(), } } -/// `v`, with its strings sent to the temp location when `k` is a path key in any command -/// ([`is_path_key`]), and the strings under path keys in objects inside it too. -pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value { - redirect(key_location(k), v, safe_path) +/// `v`, sent under key `k` at `place`, as [`redirect`] makes it. +fn redirect_under(place: ParamsPlace, k: &str, v: &Value, safe_path: &str) -> Value { + let (below, kind) = place.member(k, None); + redirect(below, kind, v, safe_path) } -/// `v` as [`safe_value`] makes it for command `spec` ([`location_in`]): a key that names part of -/// the document in it keeps its strings. +/// `v`, with its strings sent to the temp location when `k` is a path key in a command that gives it +/// no other meaning ([`key_location`]), and the strings under path keys in objects inside it too. +pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value { + redirect_under(ParamsPlace::top(None), k, v, safe_path) +} + +/// `v` as [`safe_value`] makes it for command `spec` ([`vectorcraft_engine::cmd::location_in`]): a key that names part of +/// the document in it keeps its strings, at the top level and below. pub fn safe_value_for_command(spec: &CommandSpec, k: &str, v: &Value, safe_path: &str) -> Value { - redirect(location_in(spec, k), v, safe_path) + redirect_under(ParamsPlace::top(Some(spec)), k, v, safe_path) } /// A deterministic list of junk param objects for `doc` (the command's params doc): each /// documented key set to each junk value, all keys junk at once, wrong top-level types. A string -/// under a key that names a file or folder in any command ([`is_path_key`]) is a temp location. +/// under a key that names a file or folder in a command that gives it no other meaning +/// ([`key_location`]) is a temp location. pub fn junk_params(doc: &str, safe_path: &str) -> Vec { - junk_params_with(¶m_keys(doc), |k, v| safe_value(k, v, safe_path), key_location, safe_path) + junk_params_with(¶m_keys(doc), ParamsPlace::top(None), safe_path) } /// [`junk_params`] for command `spec`, with the keys of its schema and its file and folder keys too /// ([`command_param_keys`]), and a string under each key that names a file or folder in it -/// ([`location_in`]) a temp location, as are the strings in an array of them and under the path +/// ([`vectorcraft_engine::cmd::location_in`]) a temp location, as are the strings in an array of them and under the path /// keys of objects in one (`paths: [{ids, path}]`), which it also sends. pub fn junk_params_for_command(spec: &CommandSpec, safe_path: &str) -> Vec { - junk_params_with(&command_param_keys(spec), |k, v| safe_value_for_command(spec, k, v, safe_path), |k| location_in(spec, k), safe_path) + junk_params_with(&command_param_keys(spec), ParamsPlace::top(Some(spec)), safe_path) } -/// The junk cases for `keys`: `safe(k, v)` is what is sent for junk value `v` under `k`, and -/// `kind(k)` what `k` names on disk, if anything, whose keys also get a list of locations and a -/// list of objects with a `path`. -fn junk_params_with(keys: &[String], safe: impl Fn(&str, &Value) -> Value, kind: impl Fn(&str) -> Option, safe_path: &str) -> Vec { +/// The junk cases for `keys` at `place`, the top of a command's params: what is sent for a junk value +/// under a key is [`redirect_under`] it, and a key that names a file or a folder there also gets a +/// list of locations and a list of objects with a `path`. +fn junk_params_with(keys: &[String], place: ParamsPlace, safe_path: &str) -> Vec { let junk = junk_values(); let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})]; for k in keys { + let (below, kind) = place.member(k, None); for (i, v) in junk.iter().enumerate() { - let v = safe(k, v); + let v = redirect(below, kind, v, safe_path); // Sparse: one key at a time with every junk value, plus a few combos. let mut o = serde_json::Map::new(); o.insert(k.clone(), v.clone()); @@ -598,17 +605,56 @@ fn junk_params_with(keys: &[String], safe: impl Fn(&str, &Value) -> Value, kind: if i % 5 == 0 { let mut all = serde_json::Map::new(); for k2 in keys { - let v2 = kind(k2).map_or_else(|| v.clone(), |l| json!(location(l, safe_path))); + let v2 = place.member(k2, None).1.map_or_else(|| v.clone(), |l| json!(location(l, safe_path))); all.insert(k2.clone(), v2); } out.push(Value::Object(all)); } } - if let Some(l) = kind(k) { + if let Some(l) = kind { for v in [json!(["junk", "", 1]), json!([{"ids": [1], "path": "junk"}, {"path": null}])] { - out.push(json!({ k.as_str(): redirect(Some(l), &v, safe_path) })); + out.push(json!({ k.as_str(): redirect(below, Some(l), &v, safe_path) })); } } } out } + +#[cfg(test)] +mod tests { + use super::*; + + /// The strings the sweeps send keep the meaning the command gives their key, at every depth: an + /// export's folder grouping and a web link stay as sent, also inside a `command.batch` step and + /// a recorded step, and a real file or folder key there goes to the temp location. + #[test] + fn nested_locations_keep_command_meaning() { + let spec = |id: &str| command_specs().iter().find(|c| c.id == id).unwrap_or_else(|| panic!("no command {id}")); + let safe = "/tmp/sweep/out.bin"; + let settings = json!({"folders": "rows", "formats": [{"format": "png", "folder": "junk"}]}); + assert_eq!( + safe_value_for_command(spec("document.exportPresets.save"), "settings", &settings, safe), + json!({"folders": "rows", "formats": [{"format": "png", "folder": "/tmp/sweep/out.bin-folder"}]}) + ); + let steps = json!([ + {"command": "attributes.set", "params": {"url": "https://example.com/a"}}, + {"command": "document.save", "params": {"path": "junk"}}, + {"command": "object.path.simplify", "params": {"wholePaths": "junk"}}, + ]); + assert_eq!( + safe_value_for_command(spec("command.batch"), "commands", &steps, safe), + json!([ + {"command": "attributes.set", "params": {"url": "https://example.com/a"}}, + {"command": "document.save", "params": {"path": safe}}, + {"command": "object.path.simplify", "params": {"wholePaths": "junk"}}, + ]) + ); + let recorded = json!([["object.setLiveShape", {"path": "junk"}], ["document.compare", {"diffPath": "junk"}]]); + assert_eq!( + safe_value("steps", &recorded, safe), + json!([["object.setLiveShape", {"path": "junk"}], ["document.compare", {"diffPath": safe}]]) + ); + assert_eq!(safe_value("url", &json!("https://example.com/a"), safe), json!("https://example.com/a")); + assert_eq!(safe_value("destinationFolder", &json!("junk"), safe), json!("/tmp/sweep/out.bin-folder")); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 89ab7e37b4..2032995b11 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -41,6 +41,7 @@ use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::mpsc::Sender; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::ParamsPlace; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -424,17 +425,19 @@ impl Hiding { } /// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths -/// ([`unreadable_path_like`]) or held by a path param -/// ([`vectorcraft_engine::file_access::is_path_key`]), a relative one too, each as a hidden library id -/// (`loaded/`) with `loaded/` shown. A reply hides them as it hides a hidden library's -/// path, with that file name also for a hidden library shown as `loaded/ (2)`, so that it -/// reads the same whether or not a library was loaded from one of them. +/// ([`unreadable_path_like`]) or held by a path param, a relative one too, each as a hidden library id +/// (`loaded/`) with `loaded/` shown. A param is a path param as the command it is +/// for reads it ([`ParamsPlace`]: `engine.execute`'s `params`, a `command.batch` step's and a +/// recorded step's), so a web link or an export's folder grouping is not one. A reply hides them as +/// it hides a hidden library's path, with that file name also for a hidden library shown as +/// `loaded/ (2)`, so that it reads the same whether or not a library was loaded from one +/// of them. fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { - collect_sent_paths(v, false, out); + collect_sent_paths(v, ParamsPlace::top(None), false, out); } -/// [`sent_paths`] on `v`, which a path param holds when `path_param` is set. -fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String)>) { +/// [`sent_paths`] on `v`, found at `place`, which a path param holds when `path_param` is set. +fn collect_sent_paths(v: &Value, place: ParamsPlace, path_param: bool, out: &mut Vec<(String, String)>) { fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) { if !unreadable_path_like(s) && !(path_param && !readable_value(s)) { return; @@ -456,11 +459,18 @@ fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String } match v { Value::String(s) => add(s, path_param, out), - Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)), + Value::Array(items) => { + let step = ParamsPlace::step(items); + for (i, item) in items.iter().enumerate() { + collect_sent_paths(item, place.item(i, step), path_param, out); + } + } Value::Object(fields) => { + let carried = ParamsPlace::carried(fields); for (key, item) in fields { add(key, false, out); - collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out); + let (below, location) = place.member(key, carried); + collect_sent_paths(item, below, location.is_some(), out); } } _ => {} @@ -1045,45 +1055,55 @@ fn percent_decoded(s: &str) -> String { String::from_utf8_lossy(&out).into_owned() } -/// The params of each step of each action of action set `set` (`ui.action_sets`). -fn steps_mut(set: &mut Value) -> impl Iterator> { +/// The params of each step of each action of action set `set` (`ui.action_sets`), each with the top +/// of its command's params ([`ParamsPlace::of`] the step's command id). +fn steps_mut(set: &mut Value) -> impl Iterator)> { let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten(); let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten(); - steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut)) + steps.filter_map(|step| { + let place = ParamsPlace::of(step.get(0).and_then(Value::as_str).unwrap_or_default()); + step.get_mut(1).and_then(Value::as_object_mut).map(|params| (place, params)) + }) } -/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the -/// paths the request may not read: the value of a path param -/// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder` -/// and the like), each path of a list under such a param, and under any other param, as a key too, a -/// string written as a path ([`unreadable_path_like`]). -fn leave_out_unreadable_params(params: &mut serde_json::Map) { - use vectorcraft_engine::file_access::is_path_key; +/// Takes out of a recorded step's params at `place`, at any depth (the commands of a `command.batch` +/// step), the paths the request may not read: the value of a path param as the command reads it +/// ([`ParamsPlace`]: `path`, `file`, `diffPath`, `destinationFolder` and the like, but not a key that +/// names part of the document in that command), each path of a list under such a param, and under +/// any other param, as a key too, a string written as a path ([`unreadable_path_like`]). A web link, +/// a choice or a switch whose name ends like a path param's (`alignToPath`, `linksFolder`) stays. +fn leave_out_unreadable_params(params: &mut serde_json::Map, place: ParamsPlace) { + let carried = ParamsPlace::carried(params); params.retain(|key, v| { !unreadable_path_like(key) && match v.as_str() { - Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)), + Some(p) => !unreadable_path_like(p) && !(place.member(key, carried).1.is_some() && !readable_value(p)), None => true, } }); + let carried = ParamsPlace::carried(params); for (key, v) in params.iter_mut() { + let (below, location) = place.member(key, carried); if let Value::Array(paths) = v - && is_path_key(key) + && location.is_some() { paths.retain(|p| p.as_str().is_none_or(readable_value)); } - leave_out_unreadable_values(v); + leave_out_unreadable_values(v, below); } } -/// [`leave_out_unreadable_params`] on each object `v` holds, and a string written as a path that the -/// request may not read ([`unreadable_path_like`]) taken out of each list. -fn leave_out_unreadable_values(v: &mut Value) { +/// [`leave_out_unreadable_params`] on each object `v`, found at `place`, holds, and a string written +/// as a path that the request may not read ([`unreadable_path_like`]) taken out of each list. +fn leave_out_unreadable_values(v: &mut Value, place: ParamsPlace) { match v { - Value::Object(fields) => leave_out_unreadable_params(fields), + Value::Object(fields) => leave_out_unreadable_params(fields, place), Value::Array(items) => { items.retain(|item| !item.as_str().is_some_and(unreadable_path_like)); - items.iter_mut().for_each(leave_out_unreadable_values); + let step = ParamsPlace::step(items); + for (i, item) in items.iter_mut().enumerate() { + leave_out_unreadable_values(item, place.item(i, step)); + } } _ => {} } @@ -1103,8 +1123,8 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { } } } - for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) { - leave_out_unreadable_params(step); + for (place, step) in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) { + leave_out_unreadable_params(step, place); } if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { leave_out_unreadable_folders(prefs); @@ -1231,7 +1251,7 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: return; } let mut given = Vec::new(); - written_paths(sent, false, &mut given); + written_paths(sent, ParamsPlace::top(None), false, &mut given); let kept = kept_written_paths(reply, &given); let mut hiding = unreadable_library_ids(app); hiding.merge(before); @@ -1242,16 +1262,24 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: restore_written_paths(reply, kept); } -/// The strings that `v`'s path params hold at any depth ([`vectorcraft_engine::file_access::is_path_key`]) -/// that name a file inside the write roots; `v` is a path param's value when `path_param` is set. -fn written_paths(v: &Value, path_param: bool, out: &mut Vec) { - use vectorcraft_engine::file_access::{Access, current, is_path_key}; +/// The strings that `v`'s path params hold at any depth, as the command they are for reads them +/// ([`ParamsPlace`]), that name a file inside the write roots; `v`, found at `place`, is a path +/// param's value when `path_param` is set. +fn written_paths(v: &Value, place: ParamsPlace, path_param: bool, out: &mut Vec) { + use vectorcraft_engine::file_access::{Access, current}; match v { Value::String(s) if path_param && !s.is_empty() && current().is_some_and(|r| r.check(s, Access::Write).is_ok()) => out.push(s.clone()), - Value::Array(items) => items.iter().for_each(|item| written_paths(item, path_param, out)), + Value::Array(items) => { + let step = ParamsPlace::step(items); + for (i, item) in items.iter().enumerate() { + written_paths(item, place.item(i, step), path_param, out); + } + } Value::Object(fields) => { + let carried = ParamsPlace::carried(fields); for (key, item) in fields { - written_paths(item, is_path_key(key), out); + let (below, location) = place.member(key, carried); + written_paths(item, below, location.is_some(), out); } } _ => {} diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index ac8fbfa3eb..ec412ce413 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -301,10 +301,71 @@ fn a_url_naming_another_host_stays_without_a_read_root() { assert!(super::readable_value("file://host/share/a.svg")); let mut params = json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}); let fields = params.as_object_mut().unwrap(); - super::leave_out_unreadable_params(fields); + super::leave_out_unreadable_params(fields, vectorcraft_engine::cmd::ParamsPlace::top(None)); assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]})); } +/// Under a read root, a recorded step loses a relative path outside it under a key that names a file +/// or a folder as its command reads it (also inside a `command.batch` step), and a `file:` URL outside +/// it under any key. A web link, a choice or a switch whose name ends like a path key's, and a key +/// that names part of the document in its command, keep their values. +#[test] +fn recorded_steps_filter_only_unreadable_locations() { + use vectorcraft_engine::cmd::ParamsPlace; + let root = std::env::temp_dir().join(format!("vc-recorded-locations-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let steps = [ + ("document.compare", json!({"diffPath": "elsewhere/a.svg", "directory": "elsewhere", "keep": 1})), + ("attributes.set", json!({"url": "https://example.com/elsewhere/a.html", "note": "kept"})), + ("object.slice.options", json!({"url": "file:///elsewhere/a.html"})), + ("object.path.simplify", json!({"wholePaths": "elsewhere/a", "alignToPath": "elsewhere/b", "linksFolder": "elsewhere/c"})), + ("document.exportPresets.save", json!({"settings": {"folders": "item/format", "formats": [{"folder": "elsewhere/png"}]}})), + ("object.setLiveShape", json!({"path": "elsewhere/shape"})), + ("command.batch", json!({"commands": [{"command": "document.save", "params": {"path": "elsewhere/b.svg"}}]})), + ]; + let filtered: Vec = vectorcraft_engine::file_access::confine(roots.as_ref(), || { + steps + .iter() + .map(|(id, params)| { + let mut params = params.clone(); + super::leave_out_unreadable_params(params.as_object_mut().unwrap(), ParamsPlace::of(id)); + params + }) + .collect() + }); + let _ = std::fs::remove_dir_all(root); + let want = [ + json!({"keep": 1}), + json!({"url": "https://example.com/elsewhere/a.html", "note": "kept"}), + json!({}), + json!({"wholePaths": "elsewhere/a", "alignToPath": "elsewhere/b", "linksFolder": "elsewhere/c"}), + json!({"settings": {"folders": "item/format", "formats": [{}]}}), + json!({"path": "elsewhere/shape"}), + json!({"commands": [{"command": "document.save", "params": {}}]}), + ]; + assert_eq!(filtered, want); +} + +/// A request's relative path outside the read root is hidden in a reply where the command it calls +/// reads it as a file, and a web link or a key that names part of the document there is not. +#[test] +fn a_request_sends_paths_as_its_command_reads_them() { + let root = std::env::temp_dir().join(format!("vc-sent-command-paths-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let mut sent = Vec::new(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + super::sent_paths(&json!({"command": "attributes.set", "params": {"url": "https://example.com/a/page.html"}}), &mut sent); + super::sent_paths(&json!({"command": "object.path.simplify", "params": {"wholePaths": "out/w.svg"}}), &mut sent); + super::sent_paths(&json!({"command": "document.compare", "params": {"diffPath": "out/d.svg"}}), &mut sent); + super::sent_paths(&json!({"command": "file.place", "params": {"directory": "out/folder"}}), &mut sent); + }); + let _ = std::fs::remove_dir_all(root); + let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); + assert_eq!(sent, [pair("out/d.svg", "d.svg"), pair("out/folder", "folder")]); +} + /// A hidden path quoted with `{:?}` is hidden with its quotes and control characters escaped. #[test] fn a_reply_hides_a_quoted_path_with_escaped_quotes() { From 5df244f662151942b3182f72ff1b783feead7072 Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:11:56 +0100 Subject: [PATCH 03/82] Docs: record the Pencil ignoring Smart Guides (#7252) --- docs/mcp.md | 3 ++- docs/target-app-parity.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 457d83abac..45b40866b8 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 ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252; 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 @@ -11329,5 +11329,6 @@ the error text. The interactive fallback applies only to ordinary File > Save, n | Date | Change | |---|---| +| 2026-11-13 | The Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides; Snap to Grid and Snap to Pixel still move its ends (#7252). | | 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 75d3dbf5c5..fbe854fa19 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 ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252). > > 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: ignores Smart Guides and Snap to Point and shows no Smart Guides, hovering or drawing; a stroke's ends and both ends of its straight lines follow the hand, while Snap to Grid and Snap to Pixel still move them (#7252; `pencil_follows_the_hand_and_shows_no_smart_guides`, `pencil_ends_still_snap_to_the_grid_and_pixels` in `crates/tools/src/draw2/gesture/tests.rs`). Public basis: community experts on the reference app's forum say Smart Guides and the other snapping options don't make the Pencil snap ([Drawing and snapping to path in Illustrator](https://community.adobe.com/questions-652/drawing-and-snapping-to-path-in-illustrator-766887)). 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. | From 2ae9a8f0f0bd172aff2c5959cc8efcac0d13b643 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 14:39:38 -0800 Subject: [PATCH 04/82] Graphs: an opacity or blend mode set on a series group is kept transparency.set and object.setProps with an object opacity or blend mode, given a series group picked with Group Selection, set the group's own opacity and blend mode, and the graph drew its series groups again without them. The graph now stores them for the series (groupOpacity and groupBlend) and puts them back on the series group when it is drawn again. Commands that clear the group's own opacity and blend mode (appearance.clear, graphicStyle.apply, appearance.transfer onto the group, graphicStyle.redefine of a linked group) remove what the graph stores. --- crates/doc/src/graph.rs | 7 +++ crates/engine/src/cmd/appearance.rs | 4 +- crates/engine/src/cmd/graph/generate.rs | 4 +- crates/engine/src/cmd/graph/series.rs | 63 ++++++++++++++----- crates/engine/src/cmd/graph/tests.rs | 80 +++++++++++++++++++++++- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/paint.rs | 2 +- docs/mcp.md | 11 +++- docs/target-app-parity.md | 2 +- 9 files changed, 147 insertions(+), 28 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 43074a4e80..c7649a9259 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -363,6 +363,13 @@ pub struct SeriesPaint { /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. #[serde(skip_serializing_if = "Option::is_none")] pub blend: Option, + /// Object opacity (0..1) of the series group itself, taken from the opacity set on the group. Absent: fully opaque. + /// Graph labels keep none. + #[serde(skip_serializing_if = "Option::is_none")] + pub group_opacity: Option, + /// Object blend mode of the series group itself, as [`Self::group_opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub group_blend: Option, } /// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index ca0202e858..3276859424 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -477,7 +477,8 @@ pub(crate) fn stood_for(own: &[NodeId], ids: &[NodeId]) -> Vec { } /// Clear Appearance on each series group a command gave a whole new appearance through its paths, so the group's own -/// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it. +/// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it. The graph stops keeping the +/// groups' opacity and blend mode. pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) { for id in groups { if let Some(n) = d.node_mut(*id) { @@ -486,6 +487,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) { n.extras_mut().graphic_style = None; } } + super::graph::capture_series_object_opacity(d, groups); } /// [`whole_targets`] of `cmd`, which needs at least one. diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index a789304b24..f69536e34e 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,7 @@ use super::labels::{ recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; -use super::series::{series_marks, with_series_opacity}; +use super::series::{series_marks, with_series_group_opacity, with_series_opacity}; use super::{ Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, }; @@ -1006,7 +1006,7 @@ impl<'a> Plot<'a> { } } let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); - let grp = self.b.series(&name, s, with_series_opacity(g, s, items)); + let grp = with_series_group_opacity(g, s, self.b.series(&name, s, with_series_opacity(g, s, items))); self.b.out.push(grp); } self.b.out.append(&mut self.on_top); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index bfde9e881a..17b66c39b9 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -337,11 +337,12 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool } /// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity -/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object -/// opacity or blend mode changed. +/// and blend mode: a path directly in a series group sets the paths' values, and the series group itself sets the +/// group's. Called inside the same document edit, so both undo together. Returns whether a series' kept object opacity or +/// blend mode changed. pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool { let mut before = SeriesPaintsBefore::new(); - for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + for (graph, index, group, (opacity, blend)) in object_opacity_captures(doc, edited) { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; // A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is. if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index { @@ -353,7 +354,11 @@ pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; - (slot.opacity, slot.blend) = (opacity, blend); + if group { + (slot.group_opacity, slot.group_blend) = (opacity, blend); + } else { + (slot.opacity, slot.blend) = (opacity, blend); + } } settle_series_paints(doc, before) } @@ -361,20 +366,24 @@ pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId /// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying /// it. pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool { - let mut after: HashMap<(NodeId, usize), (Option, Option)> = HashMap::new(); - let stored = |graph: NodeId, index: usize| { + let mut after: HashMap<(NodeId, usize, bool), ObjectOpacity> = HashMap::new(); + let stored = |graph: NodeId, index: usize, group: bool| { let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref()); - spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend)) + let slot = spec.and_then(|g| g.series_paints.get(index)); + slot.map_or((None, None), |slot| if group { (slot.group_opacity, slot.group_blend) } else { (slot.opacity, slot.blend) }) }; - for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { - after.insert((graph, index), (opacity, blend)); + for (graph, index, group, pair) in object_opacity_captures(doc, edited) { + after.insert((graph, index, group), pair); } - after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair) + after.into_iter().any(|((graph, index, group), pair)| stored(graph, index, group) != pair) } -/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a -/// series group, in order. -fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option, Option)> { +/// An object opacity and blend mode, None for 100% and Normal. +type ObjectOpacity = (Option, Option); + +/// The graph, series, whether it is the series group itself (`true`) or a path directly in it, and the object opacity and +/// blend mode of each of `edited` that is one of those, in order. +fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, bool, ObjectOpacity)> { if edited.is_empty() { return Vec::new(); } @@ -385,14 +394,34 @@ fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, us edited .iter() .filter_map(|id| { - // Only the paths the graph draws again, not the copy of a graph design. - let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; - let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; - Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend))) + let r = members.get(id)?; + let group = r.group == *id; + // Only the series group and the paths the graph draws again, not the copy of a graph design. + let node = doc.node(*id).filter(|n| group || (doc.parent_of(*id) == Some(r.group) && matches!(n.kind, NodeKind::Path { .. })))?; + let opacity = (node.opacity < 1.0).then_some(node.opacity); + Some((r.graph, r.index, group, (opacity, (node.blend != BlendMode::Normal).then_some(node.blend)))) }) .collect() } +/// Series `i`'s group (`group`) with the object opacity and blend mode the graph keeps for it. An opacity read from a file +/// is held to 0..1, and one that is not a number is left out. +pub(super) fn with_series_group_opacity(g: &GraphSpec, i: usize, mut group: Arc) -> Arc { + let Some(slot) = g.series_paints.get(i) else { return group }; + let opacity = slot.group_opacity.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); + if opacity.is_none() && slot.group_blend.is_none() { + return group; + } + let n = Arc::make_mut(&mut group); + if let Some(o) = opacity { + n.opacity = o; + } + if let Some(b) = slot.group_blend { + n.blend = b; + } + group +} + /// Graph Data › Transpose row/column: categories become series and series categories. pub(super) fn transpose(g: &mut GraphSpec) { let cells = g.cells(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index bcd6f406ed..51b88dc4f2 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1859,8 +1859,8 @@ fn appearance_commands_given_a_series_group_edit_its_marks() { s.execute("edit.undo", &json!({})).unwrap(); let (rect, blue) = blue_style(&mut s, "Blue", false); - let was = spec(&s, id).series_paints; s.execute("object.setProps", &json!({"ids": [group.0], "opacity": 50})).unwrap(); + let was = spec(&s, id).series_paints; s.execute("appearance.transfer", &json!({"source": rect.0, "target": group.0, "copy": true})).unwrap(); assert!(series_0_group(&s, id).1); assert_eq!(s.doc().unwrap().doc.node(group).unwrap().opacity, 1.0, "the appearance replaces the group's own opacity"); @@ -2082,10 +2082,10 @@ fn a_graphic_style_applied_to_a_series_group_styles_its_marks() { let (mut s, id, marks) = stored_red_stroke(); let (group, _) = series_0_group(&s, id); let (_, blue) = blue_style(&mut s, "Blue", false); - let was = spec(&s, id).series_paints; let own = json!({"ids": [group.0], "opacity": 50, "blend": "multiply", "isolate": true, "knockout": "on", "item": null}); s.execute("transparency.set", &own).unwrap(); s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + let was = spec(&s, id).series_paints; let g = s.doc().unwrap().doc.node(group).unwrap().clone(); assert!(g.isolate && g.knockout != Default::default() && g.blend != vectorcraft_color::BlendMode::Normal); s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0]})).unwrap(); @@ -2162,6 +2162,82 @@ fn redefining_a_graphic_style_keeps_an_edited_mark_of_a_linked_series_group() { assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id))); } +/// Series `index`'s group: its object opacity and blend mode, and those of each path directly inside it. +fn group_transparency(s: &Session, id: NodeId, index: u32) -> ((f32, BlendMode), Vec<(f32, BlendMode)>) { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let g = series(&n, index); + ((g.opacity, g.blend), g.children().unwrap().iter().map(|m| (m.opacity, m.blend)).collect()) +} + +/// An object opacity and blend mode set on a selected series group are kept by the graph for the group: the series group +/// has them after the graph is drawn again, through save and open, its paths and the other series stay opaque and +/// Normal, setting them again records no step, 100% and Normal forget them, and undo takes them back. +#[test] +fn an_opacity_and_blend_mode_on_a_series_group_survive_regeneration() { + let (mut s, id, _, _) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 40, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.group_opacity, kept.group_blend), (Some(0.4), Some(BlendMode::Multiply))); + assert_eq!((kept.opacity, kept.blend), (None, None), "the paths keep their own"); + let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap(); + assert_eq!(restored, spec(&s, id)); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = (1.0, BlendMode::Normal); + let (own, paths) = group_transparency(&s, id, 0); + assert_eq!(own, (0.4, BlendMode::Multiply)); + assert!(!paths.is_empty() && paths.iter().all(|t| *t == normal), "{paths:?}"); + let (own, paths) = group_transparency(&s, id, 1); + assert_eq!(own, normal); + assert!(paths.iter().all(|t| *t == normal), "{paths:?}"); + + let (group, _) = series_0_group(&s, id); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.setProps", &json!({"id": group.0, "opacity": 40, "blend": "multiply"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "the values the group and the graph have already"); + s.execute("object.setProps", &json!({"id": group.0, "opacity": 100, "blend": "normal"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.group_opacity, kept.group_blend), (None, None)); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[2, 2], [3, 4]]})).unwrap(); + assert_eq!(group_transparency(&s, id, 0).0, normal); + for _ in 0..4 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored"); +} + +/// Commands that clear a series group's own opacity and blend mode also remove what the graph keeps for it: Clear +/// Appearance on the group, a graphic style applied to it and Move Appearance onto it. +#[test] +fn clearing_a_series_groups_opacity_forgets_what_the_graph_keeps() { + let kept = |s: &Session, id: NodeId| { + let slot = spec(s, id).series_paints[0].clone(); + (slot.group_opacity, slot.group_blend) + }; + let faded = (Some(0.5), Some(BlendMode::Screen)); + let (mut s, id, _, _) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", false); + let (group, _) = series_0_group(&s, id); + s.execute("object.setProps", &json!({"id": group.0, "opacity": 50, "blend": "screen"})).unwrap(); + assert_eq!(kept(&s, id), faded); + let commands = [ + ("appearance.clear", json!({"ids": [group.0]})), + ("graphicStyle.apply", json!({"name": "Blue", "ids": [group.0]})), + ("appearance.transfer", json!({"source": rect.0, "target": group.0, "copy": true})), + ]; + for (cmd, params) in commands { + s.execute(cmd, ¶ms).unwrap(); + assert_eq!(kept(&s, id), (None, None), "{cmd}"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(group_transparency(&s, id, 0).0, (1.0, BlendMode::Normal), "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(kept(&s, id), faded, "{cmd}"); + } +} + /// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. #[test] fn default_fill_and_stroke_on_a_series_mark_is_kept() { diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 6ffdb04a5c..806c83bb34 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind", + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series or on a series group, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind", has_doc, set_props ), diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c7159d563a..66f1d02003 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; 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, and on a series group without an item, the group's own opacity and blend mode (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 ), diff --git a/docs/mcp.md b/docs/mcp.md index 0b636bfd61..9842f65f21 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -790,10 +790,15 @@ look was edited keep their look. Read as a source, by `graphicStyle.new`, `graph style link of its own gives the look and the style link of the topmost path directly in it, and a move with `appearance.transfer` clears each of those paths; a series group that has one of them is read itself. +`transparency.set` and `object.setProps` with an opacity or blend mode for the object, given a series group's id or the +selected series group, set the group's own opacity and blend mode, and the graph stores them for that series: the +series group takes them when the graph is drawn again, over the paths' own. Each of the commands above that clears the +group's opacity and blend mode also removes what the graph stores for it. + The other commands given a series group's id edit the group itself, and the graph does not store what they set on it: -`transparency.set` and `object.setProps` with an opacity or blend mode for the object, the `effect.*` commands and -`appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the -group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows. +`transparency.set` and `object.setProps` with isolation or knockout, the `effect.*` commands and `appearance.addEffect`, +and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the group's own stack. The +Appearance panel and `appearance.setActiveItem` read the selected group's own rows. The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8ae723dc35..1eee716d11 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1097,7 +1097,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 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. 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, 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 the object opacity and blend mode of a mark or of a series group picked with Group Selection, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A 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. 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, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. - **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. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. From 6280c90b0bbcfea0b6968b17fbc8dc28a338f6c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 15:15:58 -0800 Subject: [PATCH 05/82] Tests: split the file-key depth test a_file_key_counts_at_any_depth grew past clippy's cognitive complexity limit (34 of 30) and stopped the gate's clippy step. Its Image Trace `paths` cases, its custom arrowhead cases and its deep-nesting case now run as three tests of their own, with the same assertions. --- crates/engine/src/cmd/plugin_call.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index 4c045b5784..6d1ddbee43 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -734,7 +734,13 @@ mod tests { assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); // object.setLiveShape takes no `path`; its description says "on any path:" in its prose. assert!(names_file("object.setLiveShape", &json!({"path": "/tmp/x"}))); - // Image Trace's path fidelity setting, only inside its tracing settings. + } + + /// Image Trace's path fidelity setting `paths` names no file, but only inside its tracing + /// settings. + #[test] + fn image_trace_paths_name_no_file_only_in_its_settings() { + use serde_json::json; for id in ["imageTrace.make", "imageTrace.makeAndExpand"] { assert!(!names_file(id, &json!({"preset": "Default", "params": {"mode": "color", "paths": 50}}))); assert!(!names_file(id, &json!({"savedPreset": {"paths": 50, "corners": 75}}))); @@ -745,7 +751,12 @@ mod tests { assert!(names_file(id, &json!({"options": {"paths": 50}}))); } assert!(names_file("object.move", &json!({"params": {"paths": 50}}))); - // A custom arrowhead's outline in stroke.set, only where stroke.set takes one. + } + + /// A custom arrowhead's outline `path` names no file, but only where stroke.set takes one. + #[test] + fn a_custom_arrowhead_outline_names_no_file_only_in_stroke_set() { + use serde_json::json; let outline = json!({"subpaths": []}); assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}}))); assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"}))); @@ -755,7 +766,12 @@ mod tests { assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}}))); assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]}))); assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}}))); - // Deep nesting is walked without recursion. + } + + /// A file key under deep nesting is found, and the walk doesn't recurse. + #[test] + fn a_file_key_is_found_under_deep_nesting() { + use serde_json::json; let mut deep = json!({"file": "/tmp/x"}); for _ in 0..10_000 { deep = Value::Array(vec![deep]); From af48843ff812557bbceba140d47960cbbe15f407 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 15:31:05 -0800 Subject: [PATCH 06/82] Tests: Image Trace's top-level paths key names a file The location sweep listed imageTrace.make's `paths` as a document part at the top of its params, so catalog_path_keys_match_location_policy failed: Image Trace's path fidelity `paths` is exempt only inside its tracing settings, and a top-level `paths` names a file, as it already does for imageTrace.makeAndExpand. --- crates/engine/tests/command_sweep.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 7268c783e8..589500daff 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -384,7 +384,6 @@ const DOCUMENT_PARTS: &[(&str, &str)] = &[ ("object.pathfinder.options", "paths"), ("object.maskImage", "path"), ("text.createInPath", "path"), - ("imageTrace.make", "paths"), ("symbol.instance.setAppearance", "path"), ("symbol.instance.selectPart", "path"), ("object.objectsOnPath.attach", "path"), From 1f6c9beb8c67df16329bd5fdac1553924c76a819 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 15:46:41 -0800 Subject: [PATCH 07/82] Tests: recorded steps keep a path only where the command takes it recorded_steps_filter_only_unreadable_locations expected object.setLiveShape's `path` to stay, but that command does not take a `path` param, so the recorded step reads the key as a file and drops a path outside the read root. The case now expects that, and text.createInPath, whose `path` is the id of a path in the document, covers a key that names part of the document. --- crates/ui-egui/src/control/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index ec412ce413..97ed946339 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -308,7 +308,8 @@ fn a_url_naming_another_host_stays_without_a_read_root() { /// Under a read root, a recorded step loses a relative path outside it under a key that names a file /// or a folder as its command reads it (also inside a `command.batch` step), and a `file:` URL outside /// it under any key. A web link, a choice or a switch whose name ends like a path key's, and a key -/// that names part of the document in its command, keep their values. +/// that names part of the document in its command, keep their values; the same key in a command that +/// doesn't take it as a param names a file. #[test] fn recorded_steps_filter_only_unreadable_locations() { use vectorcraft_engine::cmd::ParamsPlace; @@ -321,6 +322,7 @@ fn recorded_steps_filter_only_unreadable_locations() { ("object.slice.options", json!({"url": "file:///elsewhere/a.html"})), ("object.path.simplify", json!({"wholePaths": "elsewhere/a", "alignToPath": "elsewhere/b", "linksFolder": "elsewhere/c"})), ("document.exportPresets.save", json!({"settings": {"folders": "item/format", "formats": [{"folder": "elsewhere/png"}]}})), + ("text.createInPath", json!({"path": "elsewhere/shape"})), ("object.setLiveShape", json!({"path": "elsewhere/shape"})), ("command.batch", json!({"commands": [{"command": "document.save", "params": {"path": "elsewhere/b.svg"}}]})), ]; @@ -342,6 +344,7 @@ fn recorded_steps_filter_only_unreadable_locations() { json!({"wholePaths": "elsewhere/a", "alignToPath": "elsewhere/b", "linksFolder": "elsewhere/c"}), json!({"settings": {"folders": "item/format", "formats": [{}]}}), json!({"path": "elsewhere/shape"}), + json!({}), json!({"commands": [{"command": "document.save", "params": {}}]}), ]; assert_eq!(filtered, want); From 89ee320591d0af6c49da87b41f5504df1030f035 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:02:54 -0800 Subject: [PATCH 08/82] Graphs: a series group also keeps its isolation and knockout transparency.set and object.setProps with isolate, knockout or knockoutShape on a series group now store them for the series (groupIsolate, groupKnockout, groupKnockoutShape), and the series group has them again when the graph is drawn again. Randomize Opacity, Sort Objects and Global Edit store the opacity they give a series group, so a redraw no longer puts back an older stored value over it. A move or copy of an appearance onto a series group whose stored values differ from its own now counts as a change, so the edit is kept. Tests cover graphicStyle.redefine of a style linked to a series group. --- crates/doc/src/graph.rs | 11 ++- crates/engine/src/cmd/addons/arrange.rs | 12 +-- crates/engine/src/cmd/appearance.rs | 10 +-- crates/engine/src/cmd/globaledit.rs | 5 +- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/graph/series.rs | 83 ++++++++++++----- crates/engine/src/cmd/graph/tests.rs | 109 ++++++++++++++++++++++- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/object/props.rs | 14 ++- crates/engine/src/cmd/paint.rs | 2 +- docs/mcp.md | 17 ++-- docs/target-app-parity.md | 2 +- 12 files changed, 220 insertions(+), 49 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index c7649a9259..eb7fc1750b 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -363,13 +363,22 @@ pub struct SeriesPaint { /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. #[serde(skip_serializing_if = "Option::is_none")] pub blend: Option, - /// Object opacity (0..1) of the series group itself, taken from the opacity set on the group. Absent: fully opaque. + /// Object opacity (0..1) of the series group itself, taken from the transparency set on the group. Absent: fully opaque. /// Graph labels keep none. #[serde(skip_serializing_if = "Option::is_none")] pub group_opacity: Option, /// Object blend mode of the series group itself, as [`Self::group_opacity`]. Absent: Normal. #[serde(skip_serializing_if = "Option::is_none")] pub group_blend: Option, + /// Isolate Blending of the series group itself, as [`Self::group_opacity`]. Absent: off. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub group_isolate: bool, + /// Knockout Group state of the series group itself, as [`Self::group_opacity`]. Absent: neutral. + #[serde(skip_serializing_if = "Option::is_none")] + pub group_knockout: Option, + /// Knockout Shape of the series group itself, as [`Self::group_opacity`]. Absent: off. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub group_knockout_shape: bool, } /// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index d7cae868ed..6e7b4a3f07 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -22,7 +22,7 @@ use vectorcraft_render::effects; use super::super::appearance::series_children_in_paint_order; use super::super::edit::selected_roots; -use super::super::graph::capture_series_object_opacity; +use super::super::graph::{capture_series_object_opacity, series_groups_among}; use super::super::menucmds::whole_or; use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; use super::super::typecmd::refresh_bounds_of; @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Randomize Opacity", ["Object", "Plug-ins", "Arrange Tools"], None, - "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}", + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw, and on a series group, the group's own (docs/mcp.md) → {count}", has_selection, randomize_opacity ), @@ -101,7 +101,7 @@ pub fn specs() -> Vec { "Sort Objects", ["Object", "Plug-ins", "Arrange Tools"], None, - "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. With apply \"opacity\", on paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md). A run that changes nothing makes no step → {count}", + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. With apply \"opacity\", on paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw, and on a series group, the group's own (docs/mcp.md). A run that changes nothing makes no step → {count}", has_multi, sort ), @@ -416,9 +416,11 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { /// Store on their graphs the opacity just given to the paths among `changed` (objects whose opacity changed) that are /// directly in a graph series, with their blend mode, so that every path of the series has them when the graph is drawn -/// again; the frontmost of them in a series sets them. +/// again; the frontmost of them in a series sets them. A series group among `changed` has its own opacity stored for the +/// group. fn keep_series_opacity(d: &mut vectorcraft_doc::Document, changed: &[NodeId]) { - let marks = series_children_in_paint_order(d, changed); + let mut marks = series_children_in_paint_order(d, changed); + marks.extend(series_groups_among(d, changed)); capture_series_object_opacity(d, &marks); } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index e44bcaf5d8..53ff0dfcda 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -478,8 +478,8 @@ pub(crate) fn stood_for(own: &[NodeId], ids: &[NodeId]) -> Vec { /// Clear Appearance on each series group a command gave a whole new appearance through its paths, so the group's own /// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it. The graph stops keeping the -/// groups' opacity and blend mode. -pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) { +/// groups' opacity, blend mode, isolation and knockout. Returns whether what a graph keeps changed. +pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) -> bool { for id in groups { if let Some(n) = d.node_mut(*id) { clear(n); @@ -487,7 +487,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) { n.extras_mut().graphic_style = None; } } - super::graph::capture_series_object_opacity(d, groups); + super::graph::capture_series_object_opacity(d, groups) } /// [`whole_targets`] of `cmd`, which needs at least one. @@ -912,7 +912,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result { (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); super::object::snap_if_aligned(to); } - clear_stood_for(d, &group); + let forgot = clear_stood_for(d, &group); for id in &cleared { if let Some(n) = d.node_mut(*id) { clear(n); @@ -930,7 +930,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result { // The targets last: on two labels of one kind, the target's rows are the ones the kind keeps. let labels = super::graph::capture_label_rows(d, &changed); let all: Vec = from.iter().chain(&targets).chain(&group).copied().collect(); - Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels)) + Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels || forgot)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index f454d7133b..edb87feb7b 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -795,7 +795,8 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se } /// Store on their graphs what the members `edited`, and the objects in them, took on the paths directly in a graph -/// series, for the graph's next redraw. The edited member is left to the edit's own command. +/// series, and the transparency a series group among them took, for the graph's next redraw. The edited member is left to +/// the edit's own command. fn keep_series_changes(before: &Document, d: &mut Document, edited: &[NodeId]) { let mut taken = vec![]; for id in edited { @@ -804,6 +805,8 @@ fn keep_series_changes(before: &Document, d: &mut Document, edited: &[NodeId]) { } } super::appearance::capture_series_changes(before, d, &taken); + let groups = super::graph::series_groups_among(d, &taken); + super::graph::capture_series_object_opacity(d, &groups); } /// The outlines of the members of the active document's Global Edit that are not selected, hold diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index e283afd752..c5af5e9ef9 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -47,7 +47,7 @@ use series::default_series_color; pub(crate) use series::{ LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, label_object_opacity_differs, - paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, shown_paint, + paint_targets, series_group_marks, series_group_marks_where, series_groups_among, series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 17b66c39b9..fc3b9a5e9a 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -336,16 +336,16 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool settle_series_paints(doc, before) } -/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity -/// and blend mode: a path directly in a series group sets the paths' values, and the series group itself sets the -/// group's. Called inside the same document edit, so both undo together. Returns whether a series' kept object opacity or -/// blend mode changed. +/// Copy the object transparency of generated series art just edited (`edited`) into what that series keeps: a path +/// directly in a series group sets the paths' object opacity and blend mode, and the series group itself sets the +/// group's opacity, blend mode, isolation, knockout and knockout shape. Called inside the same document edit, so both undo +/// together. Returns whether what a series keeps changed. pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool { let mut before = SeriesPaintsBefore::new(); - for (graph, index, group, (opacity, blend)) in object_opacity_captures(doc, edited) { + for (graph, index, group, look) in object_opacity_captures(doc, edited) { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; // A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is. - if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index { + if look == ObjectOpacity::default() && spec.series_paints.len() <= index { continue; } before.entry(graph).or_insert_with(|| spec.series_paints.clone()); @@ -355,9 +355,10 @@ pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; if group { - (slot.group_opacity, slot.group_blend) = (opacity, blend); + (slot.group_opacity, slot.group_blend) = (look.opacity, look.blend); + (slot.group_isolate, slot.group_knockout, slot.group_knockout_shape) = (look.isolate, look.knockout, look.knockout_shape); } else { - (slot.opacity, slot.blend) = (opacity, blend); + (slot.opacity, slot.blend) = (look.opacity, look.blend); } } settle_series_paints(doc, before) @@ -370,19 +371,45 @@ pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) - let stored = |graph: NodeId, index: usize, group: bool| { let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref()); let slot = spec.and_then(|g| g.series_paints.get(index)); - slot.map_or((None, None), |slot| if group { (slot.group_opacity, slot.group_blend) } else { (slot.opacity, slot.blend) }) + slot.map_or_else(ObjectOpacity::default, |slot| { + if group { + ObjectOpacity { + opacity: slot.group_opacity, + blend: slot.group_blend, + isolate: slot.group_isolate, + knockout: slot.group_knockout, + knockout_shape: slot.group_knockout_shape, + } + } else { + ObjectOpacity { opacity: slot.opacity, blend: slot.blend, ..ObjectOpacity::default() } + } + }) }; - for (graph, index, group, pair) in object_opacity_captures(doc, edited) { - after.insert((graph, index, group), pair); + for (graph, index, group, look) in object_opacity_captures(doc, edited) { + after.insert((graph, index, group), look); } - after.into_iter().any(|((graph, index, group), pair)| stored(graph, index, group) != pair) + after.into_iter().any(|((graph, index, group), look)| stored(graph, index, group) != look) } -/// An object opacity and blend mode, None for 100% and Normal. -type ObjectOpacity = (Option, Option); +/// The series groups among `ids`, for an edit that changes only what a group's transparency has beyond its opacity and +/// blend mode (isolation, knockout and knockout shape), which the graph keeps for series groups alone. +pub(crate) fn series_groups_among(doc: &Document, ids: &[NodeId]) -> Vec { + ids.iter().copied().filter(|id| doc.node(*id).is_some_and(|n| n.extras().series_index.is_some())).collect() +} -/// The graph, series, whether it is the series group itself (`true`) or a path directly in it, and the object opacity and -/// blend mode of each of `edited` that is one of those, in order. +/// An object's transparency as a graph keeps it: the object opacity and blend mode (None for 100% and Normal), and for a +/// series group its isolation, knockout (None for neutral) and knockout shape. Paths keep the first two only. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct ObjectOpacity { + opacity: Option, + blend: Option, + isolate: bool, + knockout: Option, + knockout_shape: bool, +} + +/// The graph, series, whether it is the series group itself (`true`) or a path directly in it, and the object +/// transparency of each of `edited` that is one of those, in order. fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, bool, ObjectOpacity)> { if edited.is_empty() { return Vec::new(); @@ -398,18 +425,27 @@ fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, us let group = r.group == *id; // Only the series group and the paths the graph draws again, not the copy of a graph design. let node = doc.node(*id).filter(|n| group || (doc.parent_of(*id) == Some(r.group) && matches!(n.kind, NodeKind::Path { .. })))?; - let opacity = (node.opacity < 1.0).then_some(node.opacity); - Some((r.graph, r.index, group, (opacity, (node.blend != BlendMode::Normal).then_some(node.blend)))) + let mut look = ObjectOpacity { + opacity: (node.opacity < 1.0).then_some(node.opacity), + blend: (node.blend != BlendMode::Normal).then_some(node.blend), + ..ObjectOpacity::default() + }; + if group { + look.isolate = node.isolate; + look.knockout = (node.knockout != vectorcraft_doc::Knockout::Neutral).then_some(node.knockout); + look.knockout_shape = node.knockout_shape; + } + Some((r.graph, r.index, group, look)) }) .collect() } -/// Series `i`'s group (`group`) with the object opacity and blend mode the graph keeps for it. An opacity read from a file -/// is held to 0..1, and one that is not a number is left out. +/// Series `i`'s group (`group`) with the object opacity, blend mode, isolation, knockout and knockout shape the graph +/// keeps for it. An opacity read from a file is held to 0..1, and one that is not a number is left out. pub(super) fn with_series_group_opacity(g: &GraphSpec, i: usize, mut group: Arc) -> Arc { let Some(slot) = g.series_paints.get(i) else { return group }; let opacity = slot.group_opacity.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); - if opacity.is_none() && slot.group_blend.is_none() { + if opacity.is_none() && slot.group_blend.is_none() && !slot.group_isolate && slot.group_knockout.is_none() && !slot.group_knockout_shape { return group; } let n = Arc::make_mut(&mut group); @@ -419,6 +455,11 @@ pub(super) fn with_series_group_opacity(g: &GraphSpec, i: usize, mut group: Arc< if let Some(b) = slot.group_blend { n.blend = b; } + if let Some(k) = slot.group_knockout { + n.knockout = k; + } + n.isolate |= slot.group_isolate; + n.knockout_shape |= slot.group_knockout_shape; group } diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 3f9dfe813d..721bee0128 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -690,6 +690,32 @@ fn what_global_edit_gives_the_marks_of_another_series_group_survives_regeneratio assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == given)); } +/// With a whole series group picked and Global Edit on, the opacity and blend mode set on that group reach the other +/// series groups of the same form, and the graph stores them for those groups too. +#[test] +fn the_transparency_global_edit_gives_another_series_group_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Series without names draw no legend, so each series group holds only its columns, laid out like the other's. + let csv = ",,\nQ1,3,3\nQ2,5,5"; + let made = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap(); + let id = NodeId(made["id"].as_u64().unwrap()); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (first, other) = (series(&n, 0).id, series(&n, 1).id); + s.execute("object.isolate", &json!({"id": id.0})).unwrap(); + s.execute("select.set", &json!({"ids": [first.0]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); + s.execute("transparency.set", &json!({"ids": [first.0], "opacity": 40, "blend": "multiply"})).unwrap(); + let g = s.doc().unwrap().doc.node(other).unwrap().clone(); + assert_eq!((g.opacity, g.blend), (0.4, BlendMode::Multiply), "Global Edit reaches the other group"); + let kept = spec(&s, id).series_paints[1].clone(); + assert_eq!((kept.group_opacity, kept.group_blend), (Some(0.4), Some(BlendMode::Multiply))); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(group_transparency(&s, id, 1).0, (0.4, BlendMode::Multiply)); +} + /// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of /// the series again; Normal forgets it. #[test] @@ -2288,8 +2314,9 @@ fn group_transparency(s: &Session, id: NodeId, index: u32) -> ((f32, BlendMode), } /// An object opacity and blend mode set on a selected series group are kept by the graph for the group: the series group -/// has them after the graph is drawn again, through save and open, its paths and the other series stay opaque and -/// Normal, setting them again records no step, 100% and Normal forget them, and undo takes them back. +/// has them after the graph is drawn again, the graph's data reads them back unchanged after it is written to JSON, its +/// paths and the other series stay opaque and Normal, setting them again records no step, 100% and Normal forget them, +/// and undo takes them back. #[test] fn an_opacity_and_blend_mode_on_a_series_group_survive_regeneration() { let (mut s, id, _, _) = column_marks(); @@ -2356,6 +2383,84 @@ fn clearing_a_series_groups_opacity_forgets_what_the_graph_keeps() { } } +/// Redefining a graphic style a series group is linked to clears the group's own opacity and blend mode, and removes +/// what the graph keeps for them. +#[test] +fn redefining_a_style_linked_to_a_series_group_forgets_its_kept_opacity() { + let (mut s, id, _, _) = column_marks(); + let (group, _) = series_0_group(&s, id); + let (rect, _) = blue_style(&mut s, "Blue", false); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone(); + s.edit("Link", |d, _| { + let i = d.graphic_style_index("Blue").unwrap(); + (d.graphic_styles[i].opacity, d.graphic_styles[i].blend) = (0.5, BlendMode::Screen); + let g = d.node_mut(group).unwrap(); + g.appearance = style.appearance.clone().into(); + (g.opacity, g.blend) = (0.5, BlendMode::Screen); + g.extras_mut().graphic_style = Some(style.id); + Ok(()) + }) + .unwrap(); + s.execute("object.setProps", &json!({"id": group.0, "opacity": 50, "blend": "screen"})).unwrap(); + let slot = spec(&s, id).series_paints[0].clone(); + assert_eq!((slot.group_opacity, slot.group_blend), (Some(0.5), Some(BlendMode::Screen))); + assert_eq!(s.doc().unwrap().doc.node(group).unwrap().extras().graphic_style, Some(style.id), "still linked"); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00"})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + let slot = spec(&s, id).series_paints.first().cloned().unwrap_or_default(); + assert_eq!((slot.group_opacity, slot.group_blend), (None, None)); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(group_transparency(&s, id, 0).0, (1.0, BlendMode::Normal)); +} + +/// Isolate Blending, a knockout state and Knockout Shape set on a selected series group are kept by the graph for the +/// group, through the graph being drawn again; the other series' group stays as the graph draws it, and turning them off +/// forgets them. +#[test] +fn isolation_and_knockout_on_a_series_group_survive_regeneration() { + let (mut s, id, _, _) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("transparency.set", &json!({"isolate": true, "knockout": "off", "knockoutShape": true})).unwrap(); + let slot = spec(&s, id).series_paints[0].clone(); + assert_eq!((slot.group_isolate, slot.group_knockout, slot.group_knockout_shape), (true, Some(vectorcraft_doc::Knockout::Off), true)); + assert_eq!((slot.group_opacity, slot.group_blend, slot.opacity, slot.blend), (None, None, None, None)); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.setProps", &json!({"id": group.0, "isolate": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "the values the group and the graph have already"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (g, other) = (series(&n, 0), series(&n, 1)); + assert_eq!((g.isolate, g.knockout, g.knockout_shape), (true, vectorcraft_doc::Knockout::Off, true)); + assert_eq!((other.isolate, other.knockout, other.knockout_shape), (false, vectorcraft_doc::Knockout::Neutral, false)); + let (group, _) = series_0_group(&s, id); + s.execute("object.setProps", &json!({"id": group.0, "isolate": false, "knockout": "neutral", "knockoutShape": false})).unwrap(); + assert!(spec(&s, id).series_paints.iter().all(|p| *p == vectorcraft_doc::SeriesPaint::default()), "nothing is kept"); +} + +/// Randomize Opacity and Sort Objects given a series group keep the opacity they give it for the group. +#[test] +fn randomize_and_sort_opacity_on_a_series_group_are_kept() { + let (mut s, id, _, _) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [group.0], "min": 30, "max": 60, "seed": 7})).unwrap(); + let given = s.doc().unwrap().doc.node(group).unwrap().opacity; + assert!(given < 1.0); + assert_eq!(spec(&s, id).series_paints[0].group_opacity, Some(given)); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(group_transparency(&s, id, 0).0, (given, BlendMode::Normal)); + + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (first, second) = (series(&n, 0).id, series(&n, 1).id); + s.execute("object.setProps", &json!({"id": second.0, "opacity": 80})).unwrap(); + s.execute("addon.arrange.sort", &json!({"ids": [first.0, second.0], "by": "x", "apply": "opacity", "order": "descending"})).unwrap(); + let d = &s.doc().unwrap().doc; + let (a, b) = (d.node(first).unwrap().opacity, d.node(second).unwrap().opacity); + assert_eq!((a, b), (0.8, given), "the opacities trade places"); + let kept = &spec(&s, id).series_paints; + assert_eq!((kept[0].group_opacity, kept.get(1).and_then(|k| k.group_opacity)), ((a < 1.0).then_some(a), (b < 1.0).then_some(b))); +} + /// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. #[test] fn default_fill_and_stroke_on_a_series_mark_is_kept() { diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 806c83bb34..b86f260cd8 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series or on a series group, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind", + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode, and on a series group the group's opacity, blend mode, isolate, knockout and knockoutShape (exceptions: docs/mcp.md); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind", has_doc, set_props ), diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index bd00b6b323..54ad23eb2f 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -68,8 +68,14 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, and set on a label // for its kind of label, so a value a mark or label already has still replaces the one another left on the graph. let transparency = opacity.is_some() || blend.is_some(); - let stores = - || transparency && (crate::cmd::graph::series_object_opacity_differs(d, &ids) || crate::cmd::graph::label_object_opacity_differs(d, &ids)); + // A series group's isolation, knockout and knockout shape are kept for the group alone. + let group_only = !transparency && ["isolate", "knockout", "knockoutShape"].iter().any(|k| p.get(*k).is_some()); + let kept = if group_only { crate::cmd::graph::series_groups_among(d, &ids) } else { ids.clone() }; + let stores = || { + (transparency || group_only) + && (crate::cmd::graph::series_object_opacity_differs(d, &kept) + || transparency && crate::cmd::graph::label_object_opacity_differs(d, &ids)) + }; if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() { // Hidden or locked objects can't stay selected. if hides { @@ -123,8 +129,10 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }); } } + if transparency || group_only { + crate::cmd::graph::capture_series_object_opacity(d, &kept); + } if transparency { - crate::cmd::graph::capture_series_object_opacity(d, &ids); crate::cmd::graph::capture_label_object_opacity(d, &ids); } // Hidden or locked objects can't stay selected. diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 66f1d02003..971c3d7493 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke, and on a series group without an item, the group's own opacity and blend mode (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; 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, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (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 ), diff --git a/docs/mcp.md b/docs/mcp.md index d5724a0d7d..8f201f94b9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -790,15 +790,18 @@ look was edited keep their look. Read as a source, by `graphicStyle.new`, `graph style link of its own gives the look and the style link of the topmost path directly in it, and a move with `appearance.transfer` clears each of those paths; a series group that has one of them is read itself. -`transparency.set` and `object.setProps` with an opacity or blend mode for the object, given a series group's id or the -selected series group, set the group's own opacity and blend mode, and the graph stores them for that series: the -series group takes them when the graph is drawn again, over the paths' own. Each of the commands above that clears the -group's opacity and blend mode also removes what the graph stores for it. +`transparency.set` and `object.setProps` with an opacity, blend mode, isolation, knockout or knockout shape for the +object, given a series group's id or the selected series group, set them on the group itself, and the graph stores them +for that series. The series group has them again when the graph is drawn again, and they apply to the group as a whole +in addition to each path's own opacity and blend mode. `addon.arrange.randomizeOpacity` and `addon.arrange.sort` with +`apply: "opacity"` given a series group, and Global Edit when a series group takes an edit from another match, store +what they change on the group the same way. Each of the commands above that clears the group's own opacity, blend mode, isolation +and knockout also removes what the graph stores for it. The other commands given a series group's id edit the group itself, and the graph does not store what they set on it: -`transparency.set` and `object.setProps` with isolation or knockout, the `effect.*` commands and `appearance.addEffect`, -and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the group's own stack. The -Appearance panel and `appearance.setActiveItem` read the selected group's own rows. +the `effect.*` commands and `appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke +`item`, which is a row of the group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected +group's own rows. The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6d32be4196..633d99c8ea 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1097,7 +1097,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 the object opacity and blend mode of a mark or of a series group picked with Group Selection, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A 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. 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, 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, the object opacity and blend mode of a mark, and the opacity, blend mode, isolation and knockout of a series group picked with Group Selection, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A 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. 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, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. 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 2e3931babaaa23405ea1287d0769c7f93cf13a82 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:19:41 -0800 Subject: [PATCH 09/82] Control: recovery folders in a reply are hidden through the place-aware path walk The recovery-folder check that main added calls collect_sent_paths, which now takes the params place. A folder is a single string, so it passes ParamsPlace::top(None) as sent_paths does, and the result is unchanged. --- crates/ui-egui/src/control.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 87ca62614b..41744faa15 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1274,7 +1274,7 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: // The status text of a recovery copy that failed names the recovery folder it went to. let current = vectorcraft_engine::cmd::recovery::folder(&app.session).map(|(f, _)| f); for folder in app.recovery.named.iter().chain(current.as_ref()) { - collect_sent_paths(&json!(folder), true, &mut paths); + collect_sent_paths(&json!(folder), ParamsPlace::top(None), true, &mut paths); } hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); From 815c772a46ab02e470d321a3b7efb3e2fa4d75dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:13:52 -0800 Subject: [PATCH 10/82] Image Trace: presets trace at Paths 50 with new Corners and Noise Every built-in preset other than Auto Color and Flat Logo now traces at Paths 50. Corners is 100 in Technical Drawing, 75 in Default, Black and White Logo, Line Art and Outline, and 50 in the others. Noise is 1 px in Technical Drawing, 5 in High Fidelity Photo, 10 in Low Fidelity Photo, 15 in 3 Colors, 6 Colors, 16 Colors and Shades of Gray, 20 in Sketched Art, Line Art and Outline, 25 in Default and Black and White Logo, and 90 in Silhouettes. Palettes, color counts and the other options are unchanged. This changes the output of every preset but Default, Auto Color and Flat Logo. The traces take fewer anchors (High Fidelity Photo 5,494 to 3,136 and Black and White Logo 27 to 14 in crates/trace/tests/quality), and outlines on flat art lie further from the true edge (16 Colors 0.100 to 0.123 px off on average). Docs: docs/image-trace-adjustments.md, docs/image-trace-quality.md, docs/target-app-parity.md. --- crates/engine/src/cmd/buildcmds/trace.rs | 7 +++-- crates/trace/src/params.rs | 24 +++++++-------- crates/trace/src/tests.rs | 29 ++++++++++++++++++ docs/image-trace-adjustments.md | 10 +++++- docs/image-trace-quality.md | 39 ++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 11519b84be..024ae851f5 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -288,8 +288,9 @@ fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000); } diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 9c430dd19d..156d6fbef2 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -241,19 +241,19 @@ pub fn preset(name: &str) -> Option { // many as its tones need where it is not. // (Color detail 85: a colour on a hundredth of the picture is still one of its colours.) "auto color" => TraceParams { palette: Palette::Automatic, color_detail: 85.0, ..color(6, 65.0, 60.0, 16, Method::Abutting) }, - "high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping), - "low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping), - "3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting), - "6 colors" => color(6, 65.0, 60.0, 16, Method::Abutting), - "16 colors" => color(16, 70.0, 55.0, 10, Method::Abutting), - "shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 60.0, corners: 50.0, noise: 10, method: Method::Overlapping, ..d }, - "black and white logo" => bw(128, 95.0, 80.0, 8, true), - "sketched art" => bw(150, 50.0, 50.0, 100, false), - "silhouettes" => bw(200, 40.0, 60.0, 30, false), - "line art" => bw(128, 80.0, 70.0, 10, false), - "technical drawing" => bw(128, 95.0, 90.0, 4, true), + "high fidelity photo" => color(64, 50.0, 50.0, 5, Method::Overlapping), + "low fidelity photo" => color(20, 50.0, 50.0, 10, Method::Overlapping), + "3 colors" => color(3, 50.0, 50.0, 15, Method::Abutting), + "6 colors" => color(6, 50.0, 50.0, 15, Method::Abutting), + "16 colors" => color(16, 50.0, 50.0, 15, Method::Abutting), + "shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 50.0, corners: 50.0, noise: 15, method: Method::Overlapping, ..d }, + "black and white logo" => bw(128, 50.0, 75.0, 25, true), + "sketched art" => bw(150, 50.0, 50.0, 20, false), + "silhouettes" => bw(200, 50.0, 50.0, 90, false), + "line art" => bw(128, 50.0, 75.0, 20, false), + "technical drawing" => bw(128, 50.0, 100.0, 1, true), // Black outlines: lines as stroked centre lines, wider areas as stroked outlines. - "outline" => TraceParams { fills: false, strokes: true, ..bw(128, 80.0, 70.0, 10, false) }, + "outline" => TraceParams { fills: false, strokes: true, ..bw(128, 50.0, 75.0, 20, false) }, // Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel. "flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d }, _ => return None, diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index fbedb07e29..e8d9fab828 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -892,6 +892,35 @@ fn presets_resolve() { assert!(preset("nope").is_none()); } +#[test] +fn presets_have_their_paths_corners_and_noise() { + let expected: &[(&str, f64, f64, u32)] = &[ + ("Default", 50.0, 75.0, 25), + ("High Fidelity Photo", 50.0, 50.0, 5), + ("Low Fidelity Photo", 50.0, 50.0, 10), + ("3 Colors", 50.0, 50.0, 15), + ("6 Colors", 50.0, 50.0, 15), + ("16 Colors", 50.0, 50.0, 15), + ("Shades of Gray", 50.0, 50.0, 15), + ("Black and White Logo", 50.0, 75.0, 25), + ("Sketched Art", 50.0, 50.0, 20), + ("Silhouettes", 50.0, 50.0, 90), + ("Line Art", 50.0, 75.0, 20), + ("Technical Drawing", 50.0, 100.0, 1), + ("Outline", 50.0, 75.0, 20), + ]; + for &(name, paths, corners, noise) in expected { + let p = preset(name).unwrap(); + assert_eq!((p.paths, p.corners, p.noise), (paths, corners, noise), "{name}"); + } + // The color counts stay as they were. + let colors: Vec = ["High Fidelity Photo", "Low Fidelity Photo", "3 Colors", "6 Colors", "16 Colors", "Shades of Gray"] + .iter() + .map(|n| preset(n).unwrap().colors) + .collect(); + assert_eq!(colors, [64, 20, 3, 6, 16, 8]); +} + #[test] fn params_json_roundtrip_and_partial() { let p = preset("6 Colors").unwrap(); diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index f1c0b7d3dd..e35fe5453b 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -18,6 +18,13 @@ its current location. Opening the controls does not add a document undo step. | Create | Fills / Strokes | Creates filled areas, stroked lines, or both. | | Ignore Color | Color picker | Leaves out the selected color (white by default). | +The built-in presets other than Auto Color and Flat Logo all use Paths 50. Corners is 100 in +Technical Drawing, 75 in Default, Black and White Logo, Line Art and Outline, and 50 in the others. +Noise is 1 px in Technical Drawing, 5 in High Fidelity Photo, 10 in Low Fidelity Photo, 15 in 3 Colors, +6 Colors, 16 Colors and Shades of Gray, 20 in Sketched Art, Line Art and Outline, 25 in Default and +Black and White Logo, and 90 in Silhouettes (see +[image-trace-quality.md](image-trace-quality.md#preset-paths-corners-and-noise)). + Sliders apply on release, and their numeric fields can be edited directly. Each committed adjustment re-traces the selected object as one undo step and changes the preset to Custom. While dragging, the preset and Enhanced row stay in place until release so the @@ -123,7 +130,8 @@ color is averaged down further: the factor doubles until at most 150,000 remain most 16,384 pixels (at most six times). A trace that would make more than 8,000,000 anchor points (2,000,000 on the web) is refused to protect memory, with advice: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset. A trace keeps -about 90 bytes an anchor; a 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million. +about 90 bytes an anchor; a 4000 × 3000 photo at Limited 64 colors, Paths 90, Corners 25 and Noise 4 +makes about 1.9 million. **Tracing in the background.** On the desktop a trace runs on a thread of its own, so the window keeps answering while a photo or a large image is traced (#3098). Make, Make and Expand, the task bar's Image Trace and the panel's Trace and presets start it. The status bar says "Tracing the diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 40f6fe43b9..2b2b363433 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -82,6 +82,9 @@ gradient, texture, sensor noise): (The last two rows were measured without a limit; they are refused now.) The 4000 × 3000 High Fidelity Photo trace was 86,000 anchors under the old limit; a noisier scan of the same size would have been refused. At 8,000,000 anchors a trace keeps about 0.7 GB, with a peak of about 0.9 GB while tracing. +The preset rows were measured with the earlier preset values: High Fidelity Photo at Paths 90, Corners 25 +and Noise 4, Low Fidelity Photo at Paths 60, Noise 12 and 16 Colors at Paths 70, Corners 55 and Noise 10 +(see [Preset Paths, Corners and Noise](#preset-paths-corners-and-noise)). ## Collapsed loops keep few segments A small closed loop (or a piece that returns to its own junction) whose fit collapsed to a single cubic cannot stay as one, so it kept every positioned point as a straight segment. A looser Paths @@ -225,6 +228,42 @@ in a smooth area), `a_staircase_that_runs_on_from_a_smooth_area_into_an_edge_kee `a_staircase_of_mixed_one_pixel_and_longer_steps_is_one_slope_without_a_notch`, `a_run_of_two_steps_keeps_its_corners`, `a_junction_on_a_step_ends_the_staircase_there` and `a_step_whose_cracks_read_as_an_edge_ends_the_staircase_there`. +## Preset Paths, Corners and Noise +Every built-in preset other than Auto Color and Flat Logo traces at Paths 50. Corners and Noise are: + +| preset | Paths | Corners | Noise | before | +|:--|--:|--:|--:|:--| +| Default | 50 | 75 | 25 | unchanged | +| High Fidelity Photo | 50 | 50 | 5 | 90, 25, 4 | +| Low Fidelity Photo | 50 | 50 | 10 | 60, 50, 12 | +| 3 Colors | 50 | 50 | 15 | 60, 60, 20 | +| 6 Colors | 50 | 50 | 15 | 65, 60, 16 | +| 16 Colors | 50 | 50 | 15 | 70, 55, 10 | +| Shades of Gray | 50 | 50 | 15 | 60, 50, 10 | +| Black and White Logo | 50 | 75 | 25 | 95, 80, 8 | +| Sketched Art | 50 | 50 | 20 | 50, 50, 100 | +| Silhouettes | 50 | 50 | 90 | 40, 60, 30 | +| Line Art | 50 | 75 | 20 | 80, 70, 10 | +| Technical Drawing | 50 | 100 | 1 | 95, 90, 4 | +| Outline | 50 | 75 | 20 | 80, 70, 10 | + +The palettes, color counts, thresholds and other options of the presets did not change. + +Measured with the harness below (section 1, each preset on the inputs it is meant for), before and after: +anchors fall in 10 of the 12 changed presets, for example 5,494 to 3,136 (High Fidelity Photo), 33 to 27 +(16 Colors), 27 to 14 (Black and White Logo) and 43 to 28 (Line Art); Silhouettes rises from 13 to 15 and +Sketched Art does not change. Outlines on flat art lie further from the true edge: off mean 0.100 to 0.123 +px (16 Colors), 0.101 to 0.119 (6 Colors), 0.104 to 0.129 (3 Colors), 0.064 to 0.092 (Black and White Logo) +and 0.056 to 0.074 (Line Art), and off max 1.00 to 1.16 px in 6 Colors and 16 Colors. Technical Drawing +leaves 1.4 specks (was 0.5), and Outline's stroked centre lines lie 0.095 px off (was 0.086) with 2.7 +anchors a stroke (was 3.0). ΔE changes by 0.01 or less except Line Art and Outline (2.62 and 2.61 to 2.66 +and 2.67); edge ΔE rises in 8 of the 12, for example 4.79 to 5.25 (16 Colors), 3.98 to 4.46 (3 Colors) and 9.46 to 10.03 +(Black and White Logo). On synthetic shapes and photos of 300 to 4,000 px traced outside this harness, +High Fidelity Photo makes 41% fewer anchors and 16 Colors 15% fewer; the mean pixel difference of each +trace changes by −0.23 to +0.25 of a level; 16 Colors leaves 91 staircases of steps of 2 px or more where it left 85, and High +Fidelity Photo 473 where it left 461. The tables below are the previous measurement, not a rerun. +Test: `presets_have_their_paths_corners_and_noise` in `crates/trace/src/tests.rs`. + ## A simpler contour walk `contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the right. It used to build a table of outgoing directions for every pixel corner in a raster pass and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 26681e5b7f..4cecc0dc06 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1397,6 +1397,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, cut-out edges:** in Color and Grayscale, a semi-transparent pixel (alpha 128 to 254) on the edge of a cut-out image, beside a pixel that is less than half opaque, traces as a color of the fully opaque pixels within two pixels of it when its own color is that color or a darker shade of it, and of several such colors, as the one the most of those pixels have (art across a transparent gap does not count), so a darkened edge tone no longer traces as a 1 px sliver of an unrelated palette color, while a thin semi-transparent outline of another color keeps its own; code-drawn cut-out, opaque-edge and thin-outline tests in `crates/trace`. - **Image Trace, cut-out edges choose no color:** in Color and Grayscale, the edge pixels that take the color beside them, and the semi-transparent pixels within two pixels of them whose color is also the opaque art's color blended with the background, do not count when the palette or the grays are chosen, so the edge of a cut-out image no longer adds a second shade of a color to Auto Color's palette, takes away an outline's color or takes a gray of its own; beside those pixels, the anti-aliasing between a color and an outline drawn along the edge is no color of Auto Color's own when it lies at least 0.45 of the way from the color to the outline and at least one in 32 of its pixels has both around it (edge pixels that choose no color count when they lie within 2.5 Oklab × 100 of the nearest of those colors): its pixels trace as the outline, so a thin outline stays a ring of few pieces, except beside an outline along the edge with a region of 8 pixels of its own, where each takes the nearer of the two, so a wider outline is not drawn wider; an outline over the edge in the color of other art traces in that color, while an opaque rim drawn along the edge keeps its color, also where other art reaches it; a thin semi-transparent outline of another color still counts, and opaque images trace as before; code-drawn cut-out outline, rim, opaque line and grayscale edge tests in `crates/trace`. - **Image Trace, blurred edges:** in Color with a Limited palette of at most 16 colors (3 Colors, 6 Colors, 16 Colors) and in Grayscale with at most 16 grays (Shades of Gray), by VectorCraft's own rule (RGB distances, 0 to 255 a channel), a blend pixel with no unblended pixel of its own color within 4 pixels takes the nearer side of the pair of colors within 8 pixels that it is best a blend of, of the 8 colors within 8 pixels nearest to its color, when that pair explains it twice as well as its own color, or when it lies between the two and off the line between them by less than 0.15 of their distance apart; it keeps its color when it is one of the pair. Grayscale compares lightness. The in-between tones of an edge blurred over several pixels no longer trace as rings around it. High Fidelity Photo, Low Fidelity Photo and Black and White trace as before, and so do Automatic, Full Tone and Document Library palettes. Noisy and textured images settle more of their pixels. By `crates/trace/tests/quality` (256 px variants), 16 Colors on blurred flat art went from 9.0 paths to 3.0, 158 anchors to 34 and 0.239 px off to 0.069, and 6 Colors from 6.0 paths to 3.0 and 0.226 px off to 0.071; askew shapes take up to 11 px more area in colors the art does not have, and with 16 Colors the largest distance from a thin line's outline to the trace goes from 23.5 px to 45.3 (see [quality notes](image-trace-quality.md#blurred-edges)). Code-drawn blurred-disc and grayscale ramp tests in `crates/trace`. +- **Image Trace, preset Paths, Corners and Noise:** every built-in preset but Auto Color and Flat Logo traces at Paths 50; Corners is 100 in Technical Drawing, 75 in Default, Black and White Logo, Line Art and Outline and 50 in the others; Noise is 1 to 90 px by preset. Palettes and color counts are unchanged. By `crates/trace/tests/quality`, High Fidelity Photo goes from 5,494 anchors to 3,136 and Black and White Logo from 27 to 14, while 16 Colors on flat art goes from 0.100 px off to 0.123 (see [quality notes](image-trace-quality.md#preset-paths-corners-and-noise)). Test `presets_have_their_paths_corners_and_noise` in `crates/trace`. - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. From 257d45d99479ee6f972456e8ad863525dca56b25 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:18:02 -0800 Subject: [PATCH 11/82] i18n: Turkish has a row for 1,811 of the messages the untranslated report lists The Turkish catalog had no row for 1,852 of the status and error messages that the untranslated report finds in the sources, so the status bar showed those messages in English. - tr.tsv gains 1,811 `@msg` rows: the errors of commands and their parameter checks, scripts, file import and export, printing, plug-ins and Banner Hems and Grommets. The translations are VectorCraft's own, written from the English meaning with the catalog's terms and its existing feature names. Placeholders, parameter names, command ids and literal values stay as the English writes them. - The 38 listed messages that open #7211, #7116 and #7330 add tr.tsv rows for are left out, so the catalog has no duplicate key when they merge. - Three messages that open #7353 rewrites are left out: the unknown parameter error and the PDF setting range error, which have two placeholders in a row that no row can match, and the unknown parameter error for a format's settings. - 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 Turkish row of docs/localization-parity.md gives the catalog's current counts and what still falls back to English. Docs: docs/target-app-parity.md, docs/localization-parity.md. --- crates/ui-egui/src/i18n/tr.tsv | 1812 ++++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 3 files changed, 1814 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d22c3b961f..c64b7dc6b2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4741,6 +4741,7 @@ axis Both Her İkisi @msg workspace name can't be empty çalışma alanı adı boş olamaz @msg {C}: no effect at index {from} {C}: {from} endeksinde etkisi yok @msg {_1} failed: {e} {_1} başarısız oldu: {e} +@msg {saving} failed: {e} {saving} başarısız oldu: {e} @msg {_1} files can be opened but not written (see document.formats) {_1} dosyaları açılabilir ancak yazılamaz (bkz. document.formats) @msg {_1} font families available {_1} yazı tipi aileleri mevcut @msg {_1} is a live effect (Effect › Plug-ins) {_1} canlı bir efekttir (Efekt › Eklentiler) @@ -6597,6 +6598,1808 @@ axis Y Axis Y ekseni @msg no menu item that can be inserted runs this command eklenebilen hiçbir menü öğesi bu komutu çalıştırmaz @msg the action already has 10,000 steps eylemde zaten 10.000 adım var @msg the action set would be larger than 8 MiB eylem seti 8 MiB'tan büyük olurdu +# Status and error messages of commands and their parameter checks, scripts, file import and export, printing and plug-ins. +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions katı kip işlevlerinde 'caller', 'callee' ve 'arguments' kullanılamaz +@msg Alignment must be one of the choices Hizalama, seçeneklerden biri olmalıdır +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name}, Boolean olmayan bir değer üzerinde çağrıldı +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB Örnekleri CMYK olarak okunamayan CMYK görüntüler RGB olarak içe aktarıldı +@msg Cannot write to the location of “{name}”; choose where to save it “{name}” konumuna yazılamıyor; nereye kaydedileceğini seçin +@msg DCS files can only be placed linked: use File › Place with Link on DCS dosyaları yalnızca bağlantılı olarak yerleştirilebilir: Dosya › Yerleştir komutunu Bağlantı açıkken kullanın +@msg Date.prototype method called on a value that is not a Date object Date.prototype yöntemi, Date nesnesi olmayan bir değer üzerinde çağrıldı +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString, nesne olmayan bir değer üzerinde çağrıldı +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString, işlev olmayan bir değer üzerinde çağrıldı +@msg Import SVG Filter reads SVG files, not SVGZ SVG Filtresini İçe Aktar, SVGZ dosyalarını okumaz; SVG dosyalarını okur +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (konum: {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify kendini içeren bir yapıyı yazamaz +@msg Line Gap must be a number Satır Boşluğu bir sayı olmalıdır +@msg Namespace.prototype.toString needs a Namespace Namespace.prototype.toString için bir Namespace gerekir +@msg New Document profiles need the preferences folder, which this run doesn't have Yeni Belge profilleri için tercihler klasörü gerekir; bu çalıştırmada o klasör yok +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name}, sayı olmayan bir değer üzerinde çağrıldı +@msg Object.create's prototype must be an object or null Object.create için prototip bir nesne veya null olmalıdır +@msg Object.{function} was given a value that is not an object Object.{function} işlevine nesne olmayan bir değer verildi +@msg QName.prototype.toString needs a QName QName.prototype.toString için bir QName gerekir +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name}, RegExp nesnesi olmayan bir değer üzerinde çağrıldı +@msg Stencil Bridges: the selected art is too large or too detailed to bridge Şablon Köprüleri: seçili resim köprülenemeyecek kadar büyük veya ayrıntılı +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString, dize olmayan bir değer üzerinde çağrıldı +@msg The {name} profile could not be read, so the new document has the default settings: {reason} {name} profili okunamadı, bu yüzden yeni belge varsayılan ayarları kullanıyor: {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 Tuzak: {_1} çiftten fazla nesne tuzaklanacak kadar yakın duruyor veya çok fazla nesne aynı yatay aralığı paylaşıyor; bir seferde daha az nesne tuzaklayın +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Ayıklama Kutusu ve Çizgileri: resmin dışına sığan bir ayıklama çizgisi yok ve ayıklama kutusu kapalı +@msg \\ at end of pattern desenin sonunda \\ +@msg `alignOn` cannot be empty `alignOn` boş olamaz +@msg `alignOn` must be a string `alignOn` bir dize olmalıdır +@msg `align` must be a string `align` bir dize olmalıdır +@msg `answer` for this {kind} dialog must be one of: {_1} bu {kind} iletişim kutusu için `answer` şunlardan biri olmalıdır: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers` içinde {MAX_ANSWERS} taneden fazla yanıt var +@msg `answers` is larger than 1 MiB `answers` 1 MiB'tan büyük +@msg `answers` must be a list of answers `answers` bir yanıt listesi olmalıdır +@msg `artboard` must be the index of an artboard of the document `artboard`, belgedeki bir çalışma yüzeyinin dizini olmalıdır +@msg `artboardsAt` must be [x, y] `artboardsAt` [x, y] olmalıdır +@msg `artboards` names an artboard the document does not have `artboards`, belgede olmayan bir çalışma yüzeyini belirtiyor +@msg `artboards` names an artboard twice `artboards` bir çalışma yüzeyini iki kez belirtiyor +@msg `at` must be [x, y] (got {at}) `at` [x, y] olmalıdır (verilen: {at}) +@msg `at` must be a point [x, y] `at` bir [x, y] noktası olmalıdır +@msg `beside` needs `side` `beside` için `side` gerekir +@msg `code` must be a whole number `code` bir tam sayı olmalıdır +@msg `colorMode` must be one of rgb, cmyk `colorMode` şunlardan biri olmalıdır: rgb, cmyk +@msg `colors` does not apply to the {name} reduction `colors`, {name} azaltması için geçerli değildir +@msg `colors` must be a whole number from 2 to 256 `colors` 2 ile 256 arasında bir tam sayı olmalıdır +@msg `colors` must be at least 9 for the uniform reduction tekdüze azaltma için `colors` en az 9 olmalıdır +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` 1 ile {MAX_THEME_COLORS} arasında olmalıdır (verilen: {n}) +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` 0 ile {MAX_COUNT} arasında bir tam sayı olmalıdır +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` 0 ile 100 arasında bir sayı olmalıdır +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` {lo} ile {hi} pt arasında olmalıdır +@msg `focal` and `radius` cannot be used together `focal` ve `radius` birlikte kullanılamaz +@msg `focal` must be an array of [subpath, anchor] pairs `focal`, [subpath, anchor] çiftlerinden oluşan bir dizi olmalıdır +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal`, yolun bir bağlantı noktası olmayan [{si}, {ai}] çiftini belirtiyor +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` yalnızca null alır; null bir glif seçimini kaldırır (seçimi text.setGlyphAlternate yapar) +@msg `group` must be one of {_1} `group` şunlardan biri olmalıdır: {_1} +@msg `group` must be the Objects on Path group of `member` and `target` `group`, `member` ve `target` nesnelerinin Yol Üzerindeki Nesneler grubu olmalıdır +@msg `id` is an object id, not {v} `id` bir nesne kimliğidir, {v} değil +@msg `item` needs `index` `item` için `index` gerekir +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` bir karakter stiline değil, satırın ilk karakterine aittir (text.setRangeStyle ayarlar) +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` "pushInFirst", "pushOutFirst" veya "pushOutOnly" olmalıdır +@msg `leader` must be a string `leader` bir dize olmalıdır +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` 1 ile {MAX_SUGGESTIONS} arasında olmalıdır +@msg `name` must be a name that is not empty `name` boş olmayan bir ad olmalıdır +@msg `name` must not be empty `name` boş olmamalıdır +@msg `origin` must be [x, y] in points `origin` punto cinsinden [x, y] olmalıdır +@msg `picks` must be an array (got {v}) `picks` bir dizi olmalıdır (verilen: {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` 1 ile {MAX_PICKS} arasında seçim içermelidir (verilen: {_1}) +@msg `points` must hold from 2 to 100000 points `points` 2 ile 100000 arasında nokta içermelidir +@msg `radius` must be 0 or more `radius` 0 veya daha büyük olmalıdır +@msg `rect` must be [x, y, width, height] `rect` [x, y, width, height] olmalıdır +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` [x0, y0, x1, y1] olmalıdır (verilen: {r}) +@msg `replace` must be a string `replace` bir dize olmalıdır +@msg `rows` must be lists of numbers or nulls, not {r} `rows`, sayı veya null listeleri olmalıdır, {r} değil +@msg `scale` must be a number from 0.001 to 1000 `scale` 0.001 ile 1000 arasında bir sayı olmalıdır +@msg `scope.{key}` must be a non-empty array `scope.{key}` boş olmayan bir dizi olmalıdır +@msg `scope` must be "document", {artboards} or {layers} `scope` "document", {artboards} veya {layers} olmalıdır +@msg `side` must be "left", "right", "top" or "bottom" `side` "left", "right", "top" veya "bottom" olmalıdır +@msg `side` places the artboard: leave out x, y, width and height `side` çalışma yüzeyini yerleştirir: x, y, width ve height değerlerini vermeyin +@msg `source` is the only path in series group `target` `source`, `target` seri grubundaki tek yoldur +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing`, kutu boyunca {MAX_COUNT} taneden fazla çizgi oluşturuyor +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` 0'dan büyük ve en fazla {MAX_DISTANCE} pt olmalıdır +@msg `target` must be an object of the same Objects on Path group as `member` `target`, `member` ile aynı Yol Üzerindeki Nesneler grubundaki bir nesne olmalıdır +@msg `tolerance` must be from 0 to 100 pt `tolerance` 0 ile 100 pt arasında olmalıdır +@msg `version` must be 0 or {_1} `version` 0 veya {_1} olmalıdır +@msg `version` must be 0 or {newest} `version` 0 veya {newest} olmalıdır +@msg `warichuAlign` must be one of {_1} `warichuAlign` şunlardan biri olmalıdır: {_1} +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` {_1} ile {_2} arasında bir tam sayı olmalıdır +@msg `weight` must be from 0.01 to 1000 pt `weight` 0.01 ile 1000 pt arasında olmalıdır +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights`, her satır için bir tane olmak üzere {_1} sayıdan oluşan bir liste olmalıdır (verilen: {v}) +@msg `xml` must be a string `xml` bir dize olmalıdır +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}` içindeki {n} numaralı girdi, {_1} nesnesinin {start} numaralı baytındaki paragrafa önceki bir girdiden farklı girintiler veriyor +@msg `{KEY}` entry {n} must be an object `{KEY}` içindeki {n} numaralı girdi bir nesne olmalıdır +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}` içindeki {n} numaralı girdi ne `leftIndent` ne de `firstLineIndent` ayarlıyor +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}` girdi {n}: `id` bir nesne kimliği olmalıdır +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}` girdi {n}: `start` bir bayt konumu olmalıdır +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}` girdi {n}: `{k}` -{MAX_PARA} ile {MAX_PARA} pt arasında bir sayı olmalıdır +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}` girdi {n}: {_1} nesnesinin hiçbir paragrafı {start} numaralı baytta başlamıyor +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}` girdi {n}: bilinmeyen anahtar `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}` girdi {n}: {_1} bir metin nesnesi değil +@msg `{KEY}` is empty `{KEY}` boş +@msg `{KEY}` must be a list `{KEY}` bir liste olmalıdır +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` en fazla {MAX_PARAGRAPH_INDENTS} girdi alır (verilen: {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}` yanında `{other}` alamaz +@msg `{base}` is larger than {_1} MB `{base}` {_1} MB'tan büyük +@msg `{base}` is not in the DCS file's folder `{base}` DCS dosyasının klasöründe değil +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` {MAX_EDIT_WORDS} taneden fazla sözcük içeriyor +@msg `{key}` must be 0 or more `{key}` 0 veya daha büyük olmalıdır +@msg `{key}` must be [x, y] numbers `{key}` [x, y] sayıları olmalıdır +@msg `{key}` must be a list of words `{key}` bir sözcük listesi olmalıdır +@msg `{key}` must be a list, not {v} `{key}` bir liste olmalıdır, {v} değil +@msg `{key}` must be a number or null, not {v} `{key}` bir sayı veya null olmalıdır, {v} değil +@msg `{key}` must be a whole number of 0 or more `{key}` 0 veya daha büyük bir tam sayı olmalıdır +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` 0 ile {MAX_DISTANCE} pt arasında olmalıdır +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` {lo} ile {hi} arasında olmalıdır, {v} değil +@msg `{key}` must be one of auto, always, never `{key}` şunlardan biri olmalıdır: auto, always, never +@msg `{key}` must be one of miter, round, bevel `{key}` şunlardan biri olmalıdır: miter, round, bevel +@msg `{key}` must be one word of at most {_1} characters `{key}` en fazla {_1} karakterlik tek bir sözcük olmalıdır +@msg `{key}` must hold single words of at most {_1} characters `{key}` en fazla {_1} karakterlik tek sözcükler içermelidir +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}`, bir Yol Üzerindeki Nesneler grubunun yoluna iliştirilmiş bir nesneyi belirtmelidir +@msg `{key}` names an object that is hidden or locked `{key}` gizli veya kilitli bir nesneyi belirtiyor +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` ayrı bir dosyadır ve yalnızca DCS dosyasının yolunun yanında bulunabilir +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{u}` is no Unicode character `{u}` bir Unicode karakteri değil +@msg `{word}` isn't in the custom dictionary `{word}` özel sözlükte yok +@msg a CMap has too many mappings bir CMap çok fazla eşleme içeriyor +@msg a \\u escape needs four hexadecimal digits bir \\u kaçışı için dört onaltılık basamak gerekir +@msg a comma in an XPath expression must separate function arguments XPath ifadesindeki bir virgül, işlev bağımsız değişkenlerini ayırmalıdır +@msg a document holds at most {_1} SVG filters bir belge en fazla {_1} SVG filtresi içerir +@msg a document with unsaved changes has no recovery copy kaydedilmemiş değişiklikleri olan bir belgenin kurtarma kopyası yok +@msg a font's data is too large bir yazı tipinin verileri çok büyük +@msg a font's subroutines are too large bir yazı tipinin alt yordamları çok büyük +@msg a function declaration without a name adı olmayan bir işlev bildirimi +@msg a host value has too many entries bir ana bilgisayar değeri çok fazla girdi içeriyor +@msg a host value is nested too deep bir ana bilgisayar değeri çok derin iç içe geçmiş +@msg a namespace declaration uses a reserved prefix or URI bir ad alanı bildirimi ayrılmış bir önek veya URI kullanıyor +@msg a namespace qualifier is undefined bir ad alanı niteleyicisi tanımlı değil +@msg a namespace with an empty URI cannot have a prefix URI'si boş olan bir ad alanının öneki olamaz +@msg a number has no digits bir sayının rakamı yok +@msg a number's exponent has no digits bir sayının üssünde rakam yok +@msg a number's fraction has no digits bir sayının kesir kısmında rakam yok +@msg a property descriptor cannot have both a value or writable and a get or set bir özellik tanımlayıcısı hem value veya writable hem de get veya set içeremez +@msg a property descriptor must be an object bir özellik tanımlayıcısı bir nesne olmalıdır +@msg a property's {name} must be a function or undefined bir özelliğin {name} değeri bir işlev veya undefined olmalıdır +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied içindeki bir akışın kodu çözülünce 256 MB'ı aşıyor veya kod çözme parametreleri uygulanamıyor +@msg a string holds a control character bir dize denetim karakteri içeriyor +@msg a string holds an escape sequence JSON does not have bir dize JSON'da olmayan bir kaçış dizisi içeriyor +@msg a string is not closed bir dize kapatılmamış +@msg an SVG filter is named by its id: give `xml` with `name` bir SVG filtresi kimliğiyle adlandırılır: `xml` değerini `name` ile birlikte verin +@msg an XML element has two attributes with the same name bir XML öğesinde aynı ada sahip iki öznitelik var +@msg an XML value cannot be inserted into itself or its descendants bir XML değeri kendisine veya alt öğelerine eklenemez +@msg an XML value's children cannot be assigned by position bir XML değerinin alt öğeleri konuma göre atanamaz +@msg an XML value's children cannot be deleted by position bir XML değerinin alt öğeleri konuma göre silinemez +@msg an XMLList converts to XML only when it has one item bir XMLList yalnızca tek öğesi olduğunda XML'e dönüşür +@msg an XPath node test is missing bir XPath düğüm testi eksik +@msg an XPath operand is missing bir XPath işleneni eksik +@msg an XPath operator is missing bir XPath işleci eksik +@msg an XPath predicate or step cannot follow here burada bir XPath yüklemi veya adımı gelemez +@msg an XPath predicate or step follows a value that is not a node-set bir XPath yüklemi veya adımı, düğüm kümesi olmayan bir değerin ardından geliyor +@msg an XPath step is missing bir XPath adımı eksik +@msg an image couldn't be decoded and is missing from the picture bir görüntünün kodu çözülemedi ve resimde eksik +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) {w} × {h} piksellik bir görüntü dönüştürülemeyecek kadar büyük (en fazla {_1} piksel) +@msg apply was called on a value that is not a function apply, işlev olmayan bir değer üzerinde çağrıldı +@msg area type in a thread cannot use Dynamic Text: {frames} zincirli metindeki alan metni Dinamik Metin kullanamaz: {frames} +@msg array literal too long dizi değişmezi çok uzun +@msg assignment to a constant binding sabit bir bağlamaya atama +@msg at least one row that isn't excluded needs a weight above 0 hariç tutulmayan en az bir satırın ağırlığı 0'dan büyük olmalıdır +@msg bad point {v}: expected [x, y] hatalı nokta {v}: [x, y] bekleniyordu +@msg blend must be a mode name blend bir mod adı olmalıdır +@msg both sides of an XPath | must select nodes bir XPath | işlecinin iki tarafı da düğüm seçmelidir +@msg canceled iptal edildi +@msg cannot convert the object to a primitive value nesne ilkel bir değere dönüştürülemiyor +@msg cannot convert undefined or null to an object undefined veya null bir nesneye dönüştürülemez +@msg character dimensions cannot be represented with the source text transform karakter boyutları kaynak metin dönüşümüyle ifade edilemiyor +@msg character dimensions cannot be represented with the target text transform karakter boyutları hedef metin dönüşümüyle ifade edilemiyor +@msg continuation must be an array of path chunks continuation, yol parçalarından oluşan bir dizi olmalıdır +@msg continuation requires points continuation için points gerekir +@msg each path chunk must contain 1 to 4096 points her yol parçası 1 ile 4096 arasında nokta içermelidir +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) her seçim {{at: [x, y]}} veya {{rect: [x0, y0, x1, y1]}} biçimindedir (verilen: {v}) +@msg each weight must be a number of 0 or more (got {v}) her ağırlık 0 veya daha büyük bir sayı olmalıdır (verilen: {v}) +@msg finally table {t} out of range finally tablosu {t} aralık dışında +@msg flags cannot be given with a RegExp object RegExp nesnesiyle birlikte bayrak verilemez +@msg function declaration index out of range işlev bildirimi dizini aralık dışında +@msg give `char`, `text`, `code` or `unicode` `char`, `text`, `code` veya `unicode` verin +@msg give `count` or `spacing`, not both `count` veya `spacing` verin, ikisini birden değil +@msg give `name`, an SVG filter's name bir SVG filtresinin adı olan `name` değerini verin +@msg give `path`, an SVG file, or `svg`, SVG text bir SVG dosyası olan `path` veya SVG metni olan `svg` verin +@msg give `xml`, one SVG element tek bir SVG öğesi olan `xml` değerini verin +@msg give exactly one of `at`, `rect` and `points` `at`, `rect` ve `points` değerlerinden tam olarak birini verin +@msg give exactly one of `path`, `data` and `dataBase64` `path`, `data` ve `dataBase64` değerlerinden tam olarak birini verin +@msg give index or indexes, not both index veya indexes verin, ikisini birden değil +@msg give one element tek bir öğesi verin +@msg give opacity or blend opacity veya blend verin +@msg handler at {_1} expects {_2} values but its range starts with {d} {_1} konumundaki işleyici {_2} değer bekliyor, ancak aralığı {d} ile başlıyor +@msg handler depth too large işleyici derinliği çok büyük +@msg handler range {_1}..{_2} out of range işleyici aralığı {_1}..{_2} aralık dışında +@msg hiddenLayers must be true or false hiddenLayers true veya false olmalıdır +@msg hvcC hvcC +@msg images in a spot ink whose samples couldn't be read were imported in RGB örnekleri okunamayan spot mürekkepli görüntüler RGB olarak içe aktarıldı +@msg images past the most pixels one file may hold were left out bir dosyanın tutabileceği en fazla piksel sayısını aşan görüntüler dışarıda bırakıldı +@msg images too large to read were left out okunamayacak kadar büyük görüntüler dışarıda bırakıldı +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes en fazla {MAX_GUIDE_INDEXES} girdi içerebilir +@msg indexes must be an array of guide indexes indexes, kılavuz dizinlerinden oluşan bir dizi olmalıdır +@msg instruction out of range yönerge aralık dışında +@msg instruction {pc} ({op}) pops below the stack {pc} numaralı yönerge ({op}) yığının altına iniyor +@msg instruction {pc} reached with stack depths {seen} and {d} {pc} numaralı yönergeye {seen} ve {d} yığın derinlikleriyle ulaşıldı +@msg invalid array length geçersiz dizi uzunluğu +@msg invalid group geçersiz grup +@msg invalid object id {v}: expected a non-negative integer geçersiz nesne kimliği {v}: negatif olmayan bir tam sayı bekleniyordu +@msg invalid regular expression flag '{_1}' geçersiz düzenli ifade bayrağı '{_1}' +@msg invalid regular expression: {m} geçersiz düzenli ifade: {m} +@msg invalid time value geçersiz zaman değeri +@msg is a confirm's answer, whose `value` must be true or false bir confirm yanıtıdır ve `value` değeri true veya false olmalıdır +@msg is a prompt's answer, whose `value` must be a string or null bir prompt yanıtıdır ve `value` değeri bir dize veya null olmalıdır +@msg is an alert's answer, whose `value` must be "ok" bir alert yanıtıdır ve `value` değeri "ok" olmalıdır +@msg is longer than 16 KiB 16 KiB'tan uzun +@msg it draws its image through a clipping path görüntüsünü bir kırpma yolu içinden çiziyor +@msg it draws no image hiçbir görüntü çizmiyor +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes {MAX_DICTIONARY_FILE_BYTES} bayttan büyük +@msg it isn't a file bir dosya değil +@msg it names {_1} plates, and at most {MAX_PLATES} are read {_1} kalıp belirtiyor ve en fazla {MAX_PLATES} tanesi okunuyor +@msg its art is too long or too detailed for a head resmi bir ok ucu için çok uzun veya çok ayrıntılı +@msg its art paints nothing resmi hiçbir şey boyamıyor +@msg its image is too large to read görüntüsü okunamayacak kadar büyük +@msg its plates are too large: more than {MAX_SAMPLES} pixels together kalıpları çok büyük: toplamda {MAX_SAMPLES} pikselden fazla +@msg its plates are too large: {_1} plates of {w} × {h} pixels kalıpları çok büyük: {w} × {h} piksellik {_1} kalıp +@msg its plates can't be kept together kalıpları bir arada tutulamıyor +@msg its shapes could not be combined into one şekilleri tek bir şekilde birleştirilemedi +@msg jump to {pc}, past the end sonun ötesindeki {pc} konumuna atlama +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding şunlardan biri olmalıdır: {_1} +@msg localize: the first argument must be a localization object or a string localize: ilk bağımsız değişken bir yerelleştirme nesnesi veya bir dize olmalıdır +@msg missing `picks` `picks` eksik +@msg missing `word` `word` eksik +@msg must be an object with `kind` and `value` `kind` ve `value` içeren bir nesne olmalıdır +@msg must have `kind` alert, confirm or prompt `kind` değeri alert, confirm veya prompt olmalıdır +@msg must have exactly `kind` and `value` yalnızca `kind` ve `value` içermelidir +@msg needs a closing quote after the name addan sonra bir kapanış tırnağı gerekir +@msg needs a name bir ad gerekir +@msg needs a name of at most 64 bytes en fazla 64 baytlık bir ad gerekir +@msg needs a name of letters, digits and _, or a quoted name harf, rakam ve _ karakterlerinden oluşan bir ad veya tırnak içinde bir ad gerekir +@msg needs a name without control characters denetim karakteri içermeyen bir ad gerekir +@msg nesting too deep iç içe geçme çok derin +@msg no HP-GL format HP-GL biçimi yok +@msg no New Document profile called `{shown}` ({_1}) `{shown}` adlı bir Yeni Belge profili yok ({_1}) +@msg no SVG filter at index {k} {k} dizininde SVG filtresi yok +@msg no arrowheads file here burada ok uçları dosyası yok +@msg no character has code {code} hiçbir karakterin kodu {code} değil +@msg no custom arrowhead {name} (see stroke.arrowheads.list) {name} adlı özel ok ucu yok (bkz. stroke.arrowheads.list) +@msg no file to open açılacak dosya yok +@msg no instructions yönerge yok +@msg no layer to rename yeniden adlandırılacak katman yok +@msg no random numbers ({e}) rastgele sayı yok ({e}) +@msg no selected or given object has a visible 3D effect seçili veya verilen nesnelerin hiçbirinde görünür bir 3D efekti yok +@msg no such artboard {v} böyle bir çalışma yüzeyi yok: {v} +@msg no symbol named `{_1}` `{_1}` adlı bir sembol yok +@msg node {_1} already has the text variable `{_2}` {_1} düğümünde `{_2}` metin değişkeni zaten var +@msg not a DCS file bir DCS dosyası değil +@msg not a RegExp object bir RegExp nesnesi değil +@msg not a regular expression bir düzenli ifade değil +@msg not an action set: the text doesn't start with /version and a number bir eylem seti değil: metin /version ve bir sayıyla başlamıyor +@msg nothing to repeat yinelenecek bir şey yok +@msg numbers out of order in {_1} quantifier {_1} niceleyicisinde sayılar sırasız +@msg object {_1} needs a 3D effect {_1} nesnesinde bir 3D efekti olmalıdır +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic nesne {_1}: içi boş bir Kalıp Çıkarma ve Eğim efektinin kapakları yoktur: bir grafik eşlemek için Kapak seçeneğini açın +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front nesne {_1}: derinlik 0 olduğunda nesnenin tek yüzü vardır: grafiklerini ön yüze eşleyin +@msg object {_1}: {_2} nesne {_1}: {_2} +@msg object {_1}: {e} nesne {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams nesne {_1}: {index} dizinindeki {name} için ışık ön ayarı yok; ışıklarını effect.setParams ile ayarlayın +@msg opacity must be a number opacity bir sayı olmalıdır +@msg operand too large işlenen çok büyük +@msg order is a whole number from 0 to {others}, not `{v}` order 0 ile {others} arasında bir tam sayıdır, `{v}` değil +@msg path chunks must share their boundary point yol parçaları sınır noktalarını paylaşmalıdır +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false, useArtboards false olduğunda düzenlenebilir resmi korumak için çalışma yüzeylerine ihtiyaç duyar +@msg point x must be a number noktanın x değeri bir sayı olmalıdır +@msg point y must be a number noktanın y değeri bir sayı olmalıdır +@msg points must be [x,y] pairs points [x,y] çiftleri olmalıdır +@msg points must be finite canvas points points sonlu tuval noktaları olmalıdır +@msg range out of order in character class karakter sınıfında aralık sırasız +@msg reduce of an empty array with no initial value başlangıç değeri olmadan boş bir dizide reduce +@msg regular expression flag '{_1}' given twice düzenli ifade bayrağı '{_1}' iki kez verildi +@msg sampled text would exceed the canvas (coordinates out of range) örneklenen metin tuvali aşacaktır (koordinatlar aralık dışındadır) +@msg select an SVG filter bir SVG filtresi seçin +@msg select an object with a 3D effect 3D efekti olan bir nesne seçin +@msg select an object with a 3D effect, or give its id in ids 3D efekti olan bir nesne seçin veya kimliğini ids içinde verin +@msg select one layer, group or object to rename yeniden adlandırmak için tek bir katman, grup veya nesne seçin +@msg select the art to weed ayıklanacak resmi seçin +@msg select type on a path or area type yol üzerindeki yazıyı veya alan metnini seçin +@msg side is left or right, not `{v}` side left veya right olur, `{v}` değil +@msg source text transform is collapsed or unrepresentable kaynak metin dönüşümü çökmüş veya ifade edilemiyor +@msg table must be true or false table true veya false olmalıdır +@msg takes one name tek bir ad alır +@msg target text transform is collapsed or unrepresentable hedef metin dönüşümü çökmüş veya ifade edilemiyor +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer bu çok fazla parça (en fazla 100000) veya kesik çizgi adımı oluşturuyor: çizgi desenini basitleştirin veya kesik çizgileri uzatın +@msg the Layers panel is not shown Katmanlar paneli gösterilmiyor +@msg the Layers panel's search, filter or Show Layers Only hides its row Katmanlar panelinin araması, filtresi veya Yalnızca Katmanları Göster seçeneği bu satırı gizliyor +@msg the Objects on Path group is hidden or locked Yol Üzerindeki Nesneler grubu gizli veya kilitli +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes SVG dosyası {MAX_IMPORT_BYTES} bayttan büyük +@msg the XML operation needs an XML or XMLList value XML işlemi için bir XML veya XMLList değeri gerekir +@msg the XPath expression does not evaluate to a node-set XPath ifadesinin sonucu bir düğüm kümesi değil +@msg the XPath expression ends early XPath ifadesi erken bitiyor +@msg the XPath expression has an unmatched ) XPath ifadesinde eşleşmeyen bir ) var +@msg the XPath expression has an unmatched ] XPath ifadesinde eşleşmeyen bir ] var +@msg the XPath expression has an unmatched closing bracket XPath ifadesinde eşleşmeyen bir kapanış ayracı var +@msg the XPath expression selects a namespace node, which is not an XML value XPath ifadesi bir ad alanı düğümü seçiyor; bu bir XML değeri değildir +@msg the XPath expression selects the root node, which is not an XML value XPath ifadesi kök düğümü seçiyor; bu bir XML değeri değildir +@msg the XPath function {name} needs a node-set {name} XPath işlevi için bir düğüm kümesi gerekir +@msg the XPath function {name} takes the wrong number of arguments {name} XPath işlevine yanlış sayıda bağımsız değişken verildi +@msg the XPath name functions need a node-set XPath ad işlevleri için bir düğüm kümesi gerekir +@msg the XPath prefix {shown} is not bound to a namespace {shown} XPath öneki bir ad alanına bağlı değil +@msg the XPath variable ${shown} is not bound ${shown} XPath değişkeni bağlı değil +@msg the argument list is not an object bağımsız değişken listesi bir nesne değil +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it bir grafik serisindeki grafik tasarımının çizimi tasarımdan yeniden çizilir: değiştirmek için tasarımı düzenleyin +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out resim EPS için çok büyük: 256 MB'lık PostScript'ten sonrası dışarıda bırakıldı +@msg the bitmaps hold more pixels than one file may bitmapler bir dosyanın tutabileceğinden fazla piksel içeriyor +@msg the code runs past its last instruction kod son yönergesinin ötesine geçiyor +@msg the curve tolerance must be from 0.01 to 10 points eğri toleransı 0.01 ile 10 punto arasında olmalıdır +@msg the custom dictionary couldn't be saved to {_1}: {e} özel sözlük şuraya kaydedilemedi: {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is özel sözlük {_1} okunamıyor ({why}), bu yüzden olduğu gibi bırakıldı +@msg the document already has an SVG filter named {name} belgede {name} adlı bir SVG filtresi zaten var +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it belgede {name} adlı başka bir SVG filtresi var: svgFilter.apply kullanın veya düzenlemek için svgFilter.define komutunu `replace` ile kullanın +@msg the document has no SVG filter named {name} belgede {name} adlı bir SVG filtresi yok +@msg the document has no SVG filter named {r} belgede {r} adlı bir SVG filtresi yok +@msg the document has no color swatches belgede renk örneği yok +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 belgenin renk örnekleri {_1} renk içeriyor; belge azaltması en fazla 255 renk alır +@msg the file isn't valid SVG: {e} dosya geçerli bir SVG değil: {e} +@msg the file names no plate dosya hiçbir kalıp belirtmiyor +@msg the function's prototype is not an object işlevin prototipi bir nesne değil +@msg the guide is hidden or locked kılavuz gizli veya kilitli +@msg the library's file is outside the read roots kütüphanenin dosyası okuma köklerinin dışında +@msg a linked JavaScript file is outside the read roots bağlantılı bir JavaScript dosyası okuma köklerinin dışında +@msg the line is too long, or the paths it passes too many, to check çizgi denetlenemeyecek kadar uzun veya geçtiği yollar çok fazla +@msg the method needs an XML value yöntem için bir XML değeri gerekir +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) boya bir serbest biçimli degrade değil (paint.editGradient {kind} ile bir tane uygulayın) +@msg the parameters or body do not form one function parametreler veya gövde tek bir işlev oluşturmuyor +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it yapıştırılan {name} SVG filtresi belgedeki aynı adlı filtreden farklı çiziyor ve ona uyan boş bir ad yok +@msg the paths are too many or too tangled to find where they cross yollar, kesiştikleri yerler bulunamayacak kadar çok veya karışık +@msg the pen table holds more than 255 pens kalem tablosu 255 taneden fazla kalem içeriyor +@msg the plates can't be kept kalıplar korunamıyor +@msg the position table does not match the instructions konum tablosu yönergelerle eşleşmiyor +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes istem metni {MAX_PROMPT_BYTES} bayttan uzun +@msg the radix must be between 2 and 36 taban 2 ile 36 arasında olmalıdır +@msg the rectangle or line reaches too many paths to check dikdörtgen veya çizgi denetlenemeyecek kadar çok yola ulaşıyor +@msg the right side of 'in' is not an object 'in' işlecinin sağ tarafı bir nesne değil +@msg the right side of 'instanceof' is not a function 'instanceof' işlecinin sağ tarafı bir işlev değil +@msg the selected art is nested more than {MAX_DEPTH} levels deep seçili resim {MAX_DEPTH} düzeyden daha derin iç içe geçmiş +@msg the selected art is too large to measure seçili resim ölçülemeyecek kadar büyük +@msg the selection has no outlines to weed around seçimde çevresi ayıklanacak anahat yok +@msg the value bind was called on is not a function bind'ın çağrıldığı değer bir işlev değil +@msg the value is not a constructor değer bir oluşturucu değil +@msg the value is not a function değer bir işlev değil +@msg the value is not a valid XML name değer geçerli bir XML adı değil +@msg the value is not an XML attribute name değer bir XML öznitelik adı değil +@msg the value is not an XML name değer bir XML adı değil +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines ayıklama çizgileri resmin anahatlarını ölçülemeyecek kadar çok kez kesiyor; daha az çizgi kullanın +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap ayıklama çizgileri ölçülemeyecek kadar çok anahat parçasının çok yakınından geçiyor; daha az çizgi veya daha küçük bir boşluk kullanın +@msg the weeding lines would be more than {max_pieces} pieces ayıklama çizgileri {max_pieces} parçadan fazla olurdu +@msg the {_1} plate lies outside the file {_1} kalıbı dosyanın dışında +@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 bu ikili bir DXF dosyası ve okunamıyor: CAD uygulamasından ASCII DXF olarak kaydedip onu açın +@msg this warp's points can't be edited: use Reset with Mesh first bu çarpıtmanın noktaları düzenlenemez: önce Ağ ile Sıfırla komutunu kullanın +@msg toExponential() digits must be between 0 and 20 toExponential() basamak sayısı 0 ile 20 arasında olmalıdır +@msg toFixed() digits must be between 0 and 20 toFixed() basamak sayısı 0 ile 20 arasında olmalıdır +@msg toISOString is not a function toISOString bir işlev değil +@msg toPrecision() argument must be between 1 and 21 toPrecision() bağımsız değişkeni 1 ile 21 arasında olmalıdır +@msg toSource: a getter cannot be written as source toSource: bir alıcı kaynak olarak yazılamaz +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: öğelerinin yanında özellikleri, numaralandırılamayan bir özelliği veya başka bir prototipi olan bir dizi kaynak olarak yazılamaz +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: numaralandırılamayan bir özelliği olan veya prototipi Object.prototype olmayan bir nesne kaynak olarak yazılamaz +@msg toSource: the source is too long toSource: kaynak çok uzun +@msg toSource: the value contains itself toSource: değer kendini içeriyor +@msg toSource: the value has too many entries toSource: değer çok fazla girdi içeriyor +@msg toSource: the value is nested too deep toSource: değer çok derin iç içe geçmiş +@msg toString is not a function toString bir işlev değil +@msg too many arguments çok fazla bağımsız değişken +@msg too many quantifiers çok fazla niceleyici +@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`) bilinmeyen OpenType özelliği `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; kapatmak için başına - koyun; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom ve aalt bir alternatifin numarasını `tag=N` biçiminde alır, N 0 ile {_1} arasındadır, örneğin `salt=2`) +@msg unknown dither `{d}` (diffusion, pattern, noise or none) bilinmeyen renk taklidi `{d}` (diffusion, pattern, noise veya none) +@msg unknown panel `{panel}` bilinmeyen panel `{panel}` +@msg unknown parameter `{key}` bilinmeyen parametre `{key}` +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) bilinmeyen azaltma `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform veya document) +@msg unmatched ')' eşleşmeyen ')' +@msg unterminated character class sonlandırılmamış karakter sınıfı +@msg unterminated group sonlandırılmamış grup +@msg wordGap and lineGap need area type in the selection wordGap ve lineGap için seçimde alan metni olmalıdır +@msg xpath needs its variables as an object xpath, değişkenlerini bir nesne olarak ister +@msg xpath needs the expression as a string xpath, ifadeyi bir dize olarak ister +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}; ayrıca ayarları ve önbelleği {_1} konumundan {_2} konumuna geri taşınamıyor: {e} +@msg {_1} is not a folder: {e} {_1} bir klasör değil: {e} +@msg {_1}: {_2} and {_3} are in the way {_1}: {_2} ve {_3} engel oluyor +@msg {code}: {0} {code}: {0} +@msg {dir}: {e} {dir}: {e} +@msg {file} names more than one linked JavaScript file {file} birden fazla bağlantılı JavaScript dosyası belirtiyor +@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}: özel bir ok ucunun yolunda doldurulacak bir alan, en fazla {most} bağlantı noktası ve {most} alt yol olmalı, her bağlantı noktası ucundan en fazla {far} ok ucu birimi uzakta bulunmalıdır +@msg {k}: bad custom arrowhead path: {e} {k}: hatalı özel ok ucu yolu: {e} +@msg {k}: bad custom arrowhead: {e} {k}: hatalı özel ok ucu: {e} +@msg {k}: custom must be a name {k}: custom bir ad olmalıdır +@msg {name} must be a number {name} bir sayı olmalıdır +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy bir DCS dosyasını yalnızca kalıpları içindeyse alır; kalıp dosyalarının bulunduğu yerde ona yeniden bağlayın +@msg {path}: larger than {_1} MB {path}: {_1} MB'tan büyük +@msg {shown} is not an XPath 1.0 function {shown} bir XPath 1.0 işlevi değil +@msg {v} names no layer or sublayer {v} hiçbir katmanı veya alt katmanı belirtmiyor +@msg {what} is not a function {what} bir işlev değil +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what} bir yaprağa {MAX_COPIES_PER_SHEET} taneden fazla kopya yazdırırdı: ölçeğini büyütün +@msg {what} {i} out of range {what} {i} aralık dışında +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Bir profil tek bir Döndürme efektini destekler. Bunun yerine mevcut efektin seçeneklerini düzenleyin. +@msg Add Points at Crossings: a path went missing Kesişimlere Nokta Ekle: bir yol kayboldu +@msg CSS options: {e} CSS seçenekleri: {e} +@msg CSV has a header but no data rows CSV'de bir başlık var ama veri satırı yok +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV, NUL baytları içeriyor: UTF-8 olarak veya bayt sırası işaretiyle UTF-16 olarak kaydedin +@msg CSV is empty CSV boş +@msg CSV is larger than {MAX_CSV_BYTES} bytes CSV {MAX_CSV_BYTES} bayttan büyük +@msg CSV is not UTF-16 CSV, UTF-16 değil +@msg CSV is not UTF-8 CSV, UTF-8 değil +@msg CSV would be larger than {MAX_CSV_BYTES} bytes CSV {MAX_CSV_BYTES} bayttan büyük olurdu +@msg Choose original artwork, not a dimension's generated paths Bir boyutun oluşturduğu yolları değil, özgün resmi seçin +@msg Contour: select paths or shapes Kontur: yolları veya şekilleri seçin +@msg Contour: the Offset Path effect is not available Kontur: Ofset Yolu efekti kullanılamıyor +@msg Create needs fills, strokes or both Oluştur için dolgular, konturlar veya her ikisi gerekir +@msg DXF options: {e} DXF seçenekleri: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} DXF ölçeği en fazla {_1} olan pozitif bir sayı olmalıdır, {scale} değil +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Dimension is locked or hidden Boyut kilitli veya gizli +@msg Dimension mode does not match its source Boyut modu kaynağıyla eşleşmiyor +@msg Draw Inside needs exactly one selected path, compound path or text İçine Çiz için tam olarak bir seçili yol, bileşik yol veya metin gerekir +@msg EPS Save writes all artwork: use document.export for selected-only EPS EPS Kaydet tüm resmi yazar: yalnızca seçili resmi içeren EPS için document.export kullanın +@msg EPS flattener: {e} EPS düzleştirici: {e} +@msg EPS options: {e} EPS seçenekleri: {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 Ekranlar için Dışa Aktar şunları yazar: png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai veya vectorcraft +@msg Flare click coordinates must be finite numbers Mercek parlaması tıklama koordinatları sonlu sayılar olmalıdır +@msg HTML Text applies to object slices of type only HTML Metni yalnızca metin nesnesi dilimlerine uygulanır +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Nesneleri Tanımlama Ölçütü XML Kimliği: ad geçerli bir XML kimliği olmalıdır (harf, alt çizgi veya iki nokta ile başlar; boşluk içermez) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) Görüntü İzleme başarısız oldu: iç hata: iş parçacığı durdu (lütfen bu hatayı bildirin) +@msg Image Trace failed: {e} Görüntü İzleme başarısız oldu: {e} +@msg Image Trace finished, but its document was closed Görüntü İzleme tamamlandı, ancak belgesi kapatılmıştı +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Görüntü İzleme tamamlandı, ancak bu sırada görüntü veya ayarları değişti: yeniden izleyin +@msg Invalid dimension offset Geçersiz boyut ofseti +@msg Invalid point Geçersiz nokta +@msg Join: a subpath went missing Birleştir: bir alt yol kayboldu +@msg Lock Workspace is not available in VectorCraft Çalışma Alanını Kilitle, VectorCraft'ta kullanılamaz +@msg Move Panel to New Window is not available: floating panels stay in the main window Paneli Yeni Pencereye Taşı kullanılamaz: yüzen paneller ana pencerede kalır +@msg Offset Path: the path is too complex to offset Ofset Yolu: yol ofsetlenemeyecek kadar karmaşık +@msg Offset Path: {e} Ofset Yolu: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported PDF içeriği eksik veya okuyucunun sınırlarını aşıyor; hiçbir belge içe aktarılmadı +@msg PDF flattener: {e} PDF düzleştirici: {e} +@msg PDF options: {e} PDF seçenekleri: {e} +@msg Recolor Artwork is no longer open Resmi Yeniden Renklendir artık açık değil +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG; UTF-8, UTF-16 veya ISO 8859-1 değil +@msg Save writes one artboard: name one, or export several with document.export Kaydet tek bir çalışma yüzeyi yazar: birini belirtin veya birkaçını document.export ile dışa aktarın +@msg Select a dimension first Önce bir boyut seçin +@msg Select dimensions to update Güncellenecek boyutları seçin +@msg Select only dimensions Yalnızca boyutları seçin +@msg Select two or more objects, or a group İki veya daha fazla nesne ya da bir grup seçin +@msg Shaper needs 1–64 source paths Şekillendirici için 1–64 kaynak yol gerekir +@msg Switch x/y is for scatter graphs x/y Değiştir dağılım grafikleri içindir +@msg Text options: {e} Metin seçenekleri: {e} +@msg `align` is start, center or end `align` start, center veya end olur +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors`, [subpath, anchor] çiftlerinden oluşan bir dizi olmalıdır +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` bağlantı noktası dizinlerini (0, 1, 2…) listelemelidir, {k} değil +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` bağlantı noktası dizinlerini (0, 1, 2…) listelemelidir, {v} değil +@msg `artboards` must be a list of artboard indices `artboards` bir çalışma yüzeyi dizinleri listesi olmalıdır +@msg `asset` is the asset id (see assets.list) `asset` varlık kimliğidir (bkz. assets.list) +@msg `at` must be a byte offset `at` bir bayt konumu olmalıdır +@msg `autoLeading` must be a number `autoLeading` bir sayı olmalıdır +@msg `background` must be "", "matte" or #rrggbb `background` "", "matte" veya #rrggbb olmalıdır +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` "none", "standard" veya "forced" olmalıdır +@msg `center` must be [x, y] `center` [x, y] olmalıdır +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" veya "icfBottom" olmalıdır +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` "default", "leftToRight" veya "rightToLeft" olmalıdır +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke` için `stroke: false`, `ids: []` gerekir ve `stop` verilmemelidir +@msg `clear` must be a list of type object ids, not {v} `clear` bir metin nesnesi kimlikleri listesi olmalıdır, {v} değil +@msg `clear` must list type objects; {_1} is not one `clear` metin nesnelerini listelemelidir; {_1} bir metin nesnesi değil +@msg `clear` must not list `id` `clear` içinde `id` yer almamalıdır +@msg `color` needs a `rule` to make the theme from `color` için temanın oluşturulacağı bir `rule` gerekir +@msg `colors` is empty `colors` boş +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` vertical, uniform, repeating veya sliding olur +@msg `columns` must be a whole number 1..50 `columns` 1..50 arasında bir tam sayı olmalıdır +@msg `commands` must not be empty `commands` boş olmamalıdır +@msg `contents` goes with `to` `contents`, `to` ile birlikte kullanılır +@msg `corners` must be sharp, round or square, not `{other}` `corners` sharp, round veya square olmalıdır, `{other}` değil +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` bağlantı noktası dizinlerini (0, 1, 2…) listelemelidir, {v} değil +@msg `count` must be a whole number from 1 to 100 `count` 1 ile 100 arasında bir tam sayı olmalıdır +@msg `dataBase64` is larger than the module limit `dataBase64` modül sınırından büyük +@msg `dataBase64` is not valid base64 `dataBase64` geçerli bir base64 değil +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly`; `ids`, `reverse` veya `append` ile birleştirilemez +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` ve `childLimit` özeti dilimler: `summary: true` verin +@msg `design` is a design name, or null for plain columns `design` bir tasarım adıdır veya düz sütunlar için null olur +@msg `design` is a design name, or null for the default marker `design` bir tasarım adıdır veya varsayılan işaretçi için null olur +@msg `destinationFolder` is required when destination is `folder` destination `folder` olduğunda `destinationFolder` gereklidir +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` `none`, `saveAndClose` veya `folder` olmalıdır +@msg `diameter` must be from 0.1 to 2500 pt `diameter` 0.1 ile 2500 pt arasında olmalıdır +@msg `digits` must be one of {_1} `digits` şunlardan biri olmalıdır: {_1} +@msg `dimImages` is a percentage or false `dimImages` bir yüzde veya false olur +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` "auto", "leftToRight" veya "rightToLeft" olmalıdır +@msg `each` is the value one design stands for, more than 0 `each`, bir tasarımın temsil ettiği değerdir ve 0'dan büyüktür +@msg `end` must be "start" or "end" `end` "start" veya "end" olmalıdır +@msg `ends` applies to joining exactly two paths `ends` tam olarak iki yolun birleştirilmesi için geçerlidir +@msg `event` must be one of {_1} `event` şunlardan biri olmalıdır: {_1} +@msg `every` must be "all", "odd" or "even", not {v} `every` "all", "odd" veya "even" olmalıdır, {v} değil +@msg `expand` must be true or false `expand` true veya false olmalıdır +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files`, son text.findFontFiles aramasının bulduğu yazı tipi dosyalarının 1 ile 1000 arasında yolu olmalıdır +@msg `folder` must be an absolute folder path `folder` mutlak bir klasör yolu olmalıdır +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes`, eksen etiketleri ve değerlerinden oluşan bir nesne olmalıdır, örneğin {"wght"} +@msg `format` must be a string `format` bir dize olmalıdır +@msg `format` must be one of {_1} `format` şunlardan biri olmalıdır: {_1} +@msg `fractions` is chop or scale `fractions` chop veya scale olur +@msg `from` and `to` must differ `from` ve `to` farklı olmalıdır +@msg `from` has an empty frame `from` boş bir çerçeveye sahip +@msg `from` must be a non-negative integer `from` negatif olmayan bir tam sayı olmalıdır +@msg `from` must be area type or type on a path `from` alan metni veya yol üzerindeki yazı olmalıdır +@msg `gid` must be a glyph id, not {v} `gid` bir glif kimliği olmalıdır, {v} değil +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` bir karakter stiline değil, tek bir karaktere aittir (text.setGlyphAlternate ayarlar) +@msg `guide` must be a guide index (guide.list) `guide` bir kılavuz dizini olmalıdır (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` default, left, center veya right olmalıdır +@msg `id` must be a node id `id` bir düğüm kimliği olmalıdır +@msg `id` must be an object id, not {v} `id` bir nesne kimliği olmalıdır, {v} değil +@msg `ids` must name Objects on Path groups or objects in them `ids`, Yol Üzerindeki Nesneler gruplarını veya bunların içindeki nesneleri belirtmelidir +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` en fazla {MAX_INSPECT_IDS} kimlik alır +@msg `imageMap` must be none, rectangle or polygon `imageMap` none, rectangle veya polygon olmalıdır +@msg `includeArtboardFill` must be true or false `includeArtboardFill` true veya false olmalıdır +@msg `includeSubdirs` must be true or false `includeSubdirs` true veya false olmalıdır +@msg `index` must be a point index `index` bir nokta dizini olmalıdır +@msg `index` must be a whole number or null `index` bir tam sayı veya null olmalıdır +@msg `indices` must be a non-empty array of item indices `indices`, öğe dizinlerinden oluşan boş olmayan bir dizi olmalıdır +@msg `items` is a list of {kind, item} `items` bir {kind, item} listesidir +@msg `items` is empty `items` boş +@msg `items` must be a list of {id, points} `items` bir {id, points} listesi olmalıdır +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` bir {{id, anchors?}} listesi olmalıdır, {v} değil +@msg `items` must be an array of {id, anchors} `items` bir {id, anchors} dizisi olmalıdır +@msg `items` must be an array of {{id, corners?}}, not {v} `items` bir {{id, corners?}} dizisi olmalıdır, {v} değil +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` "none", "short", "medium", "long" veya "stylistic" olmalıdır +@msg `kind` is one of {_1} `kind` şunlardan biridir: {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` "round", "invertedRound" veya "chamfer" olmalıdır, {v} değil +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` `text`, `visibility`, `image` veya `graph` olmalıdır +@msg `kind` must be a string `kind` bir dize olmalıdır +@msg `kind` must be image, noImage or htmlText `kind` image, noImage veya htmlText olmalıdır +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` "none", "hard" veya "soft" olmalıdır +@msg `label` must be a non-empty name of at most 200 characters `label` en fazla 200 karakterlik, boş olmayan bir ad olmalıdır +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` bir dil adı veya etiketi olmalıdır, örneğin "Turkish" veya "tr" +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` "romanBaseline" veya "emBoxTop" olmalıdır +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` 0.1 ile 5000 arasında veya Otomatik için null olmalıdır +@msg `length` must be a number of at most 1000000 pt either way `length` her iki yönde en fazla 1000000 pt olan bir sayı olmalıdır +@msg `line` must be true or false `line` true veya false olmalıdır +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°), `maxAngle` ({max_angle}°) değerinden büyük olamaz +@msg `mode` must be "handles", "straight" or "smooth" `mode` "handles", "straight" veya "smooth" olmalıdır +@msg `mode` must be "straight", "bezier" or "arc" `mode` "straight", "bezier" veya "arc" olmalıdır +@msg `mode` must be one of {_1} `mode` şunlardan biri olmalıdır: {_1} +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" veya "lineEndHalf" olmalıdır +@msg `names` is a list of preset names `names` bir ön ayar adları listesidir +@msg `names` lists preset names `names` ön ayar adlarını listeler +@msg `names` must be a list of names `names` bir ad listesi olmalıdır +@msg `names` must be a list of swatch names `names` bir renk örneği adları listesi olmalıdır +@msg `names` must be an array of style names `names` bir stil adları dizisi olmalıdır +@msg `new` is a design name, or true `new` bir tasarım adı veya true olur +@msg `path` must be a folder `path` bir klasör olmalıdır +@msg `path` must be a path object `path` bir yol nesnesi olmalıdır +@msg `pivot` must be a whole number from 0 to 8 `pivot` 0 ile 8 arasında bir tam sayı olmalıdır +@msg `points` must be a list of [x, y] points `points` bir [x, y] noktaları listesi olmalıdır +@msg `points` must be a list of point indexes `points` bir nokta dizinleri listesi olmalıdır +@msg `points` must be an array of point indices `points` bir nokta dizinleri dizisi olmalıdır +@msg `points` must be point indexes `points` nokta dizinleri olmalıdır +@msg `port` must be "in" or "out" `port` "in" veya "out" olmalıdır +@msg `position` is above or below `position` above veya below olur +@msg `preset` must be a string `preset` bir dize olmalıdır +@msg `ratio` must be from 0.1 to 0.5 `ratio` 0.1 ile 0.5 arasında olmalıdır +@msg `rect` must be [x, y, width, height] with a positive size `rect` pozitif boyutlu [x, y, width, height] olmalıdır +@msg `relation` must be overlap, inside or outside `relation` overlap, inside veya outside olmalıdır +@msg `reversed` must be true or false `reversed` true veya false olmalıdır +@msg `rotate` must be a finite number `rotate` sonlu bir sayı olmalıdır +@msg `rule` must be nonZero or evenOdd `rule` nonZero veya evenOdd olmalıdır +@msg `scale` must be a number in (0, 100] `scale` (0, 100] aralığında bir sayı olmalıdır +@msg `scope` must be "selection" or "document", not {other} `scope` "selection" veya "document" olmalıdır, {other} değil +@msg `script` must be a string `script` bir dize olmalıdır +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes`, seri dizinlerinden oluşan boş olmayan bir listedir +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` için bir `type` veya bir `valueAxis` gerekir +@msg `shift` must be a number of points `shift` punto cinsinden bir sayı olmalıdır +@msg `showCenter` must be true or false `showCenter` true veya false olmalıdır +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` "fullJustify", "alignLeft", "alignCenter" veya "alignRight" olmalıdır +@msg `size` is a percentage of the base text's size, 1 to 100 `size`, temel metnin boyutunun yüzdesidir ve 1 ile 100 arasındadır +@msg `source` and `target` are the same object `source` ve `target` aynı nesne +@msg `source` must be `folder` or `datasets` `source` `folder` veya `datasets` olmalıdır +@msg `spacing` must be a number `spacing` bir sayı olmalıdır +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` punto cinsinden 0 veya daha büyük bir sayı ya da "auto" olmalıdır +@msg `start` must be a finite number `start` sonlu bir sayı olmalıdır +@msg `starts` must be an array of anchor indices (or nulls) `starts`, bağlantı noktası dizinlerinden (veya null değerlerinden) oluşan bir dizi olmalıdır +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` linked, embedded, missing veya modified ya da bunların bir listesi olmalıdır +@msg `stop` must be a stop index `stop` bir durak dizini olmalıdır +@msg `story` styles the whole story: give no `start` or `end` `story` tüm metin akışına stil verir: `start` veya `end` vermeyin +@msg `story` takes the whole story: give no `start` or `end` `story` tüm metin akışını alır: `start` veya `end` vermeyin +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` konturlar için geçerlidir (stroke: true) +@msg `style` must be roman or japanese `style` roman veya japanese olmalıdır +@msg `subpaths` must be two integer indices `subpaths` iki tam sayı dizini olmalıdır +@msg `symbol` must be a symbol name `symbol` bir sembol adı olmalıdır +@msg `target` must be "object" or "contents", not {v} `target` "object" veya "contents" olmalıdır, {v} değil +@msg `tint` applies to global and spot colours `tint` genel ve spot renkler için geçerlidir +@msg `tint` goes with a global or spot `swatch` `tint`, genel veya spot bir `swatch` ile birlikte kullanılır +@msg `tint` must be a number (%) `tint` bir sayı (%) olmalıdır +@msg `tint` needs a global or spot colour `tint` için genel veya spot bir renk gerekir +@msg `to` is already threaded `to` zaten zincirlenmiş +@msg `to` is the design's new name `to` tasarımın yeni adıdır +@msg `to` must be [x, y] `to` [x, y] olmalıdır +@msg `to` must be a closed path, area type or type on a path other than `from` `to`, `from` dışında kapalı bir yol, alan metni veya yol üzerindeki yazı olmalıdır +@msg `to` must be a whole number `to` bir tam sayı olmalıdır +@msg `to` takes the rows of one object's stack, not aligned rows `to` hizalanmış satırları değil, tek bir nesnenin yığınındaki satırları alır +@msg `useLab` must be true or false `useLab` true veya false olmalıdır +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` default, top, middle, baseline veya bottom olmalıdır +@msg `values` must be a list `values` bir liste olmalıdır +@msg `values` must be an object `values` bir nesne olmalıdır +@msg `values` must name hex, rgb, cmyk, lab or gray `values` hex, rgb, cmyk, lab veya gray belirtmelidir +@msg `values` names a kind twice `values` bir türü iki kez belirtiyor +@msg `{_1}` can't be edited or removed `{_1}` düzenlenemez veya kaldırılamaz +@msg `{_1}` is built in and always listed `{_1}` yerleşiktir ve her zaman listelenir +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}`, son text.findFontFiles aramasının bulduğu bir yazı tipi dosyası değil +@msg `{_1}` would be overwritten by the package: choose a different folder or name paket `{_1}` üzerine yazardı: farklı bir klasör veya ad seçin +@msg `{file}` has no art to place `{file}` içinde yerleştirilecek resim yok +@msg `{file}` is over {_1} MB `{file}` {_1} MB'tan büyük +@msg `{f}` belongs to a VectorCraft that is running `{f}` çalışmakta olan bir VectorCraft'a ait +@msg `{f}` is the copy of a document open now `{f}` şu anda açık olan bir belgenin kopyası +@msg `{id}` is no writable format (see document.formats) `{id}` yazılabilir bir biçim değil (bkz. document.formats) +@msg `{id}` isn't a loaded library `{id}` yüklenmiş bir kütüphane değil +@msg `{key}` has no colours `{key}` renk içermiyor +@msg `{key}` is a design name `{key}` bir tasarım adıdır +@msg `{key}` is a design name or a list of them `{key}` bir tasarım adı veya tasarım adları listesidir +@msg `{key}` is longer than {MAX_NAME} characters `{key}` {MAX_NAME} karakterden uzun +@msg `{key}` is out of range `{key}` aralık dışında +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` yüzde cinsinden [min, desired, max] veya {{min?, desired?, max?}} olmalıdır +@msg `{key}` must be a non-negative integer `{key}` negatif olmayan bir tam sayı olmalıdır +@msg `{key}` must be a number {_1}..{_2} `{key}` {_1}..{_2} aralığında bir sayı olmalıdır +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` en az 0 olan bir tam sayı olmalıdır, {n} değil +@msg `{key}` must be an appearance item index (paint order) or null `{key}` bir görünüm öğesi dizini (boyama sırası) veya null olmalıdır +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` bir varlık kimlikleri dizisi olmalıdır (bkz. assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` 0 ile 180 derece arasında olmalıdır, {a} değil +@msg `{key}` must be from {_1} to {shift_max} `{key}` {_1} ile {shift_max} arasında olmalıdır +@msg `{key}`: not a colour: {v} `{key}`: bir renk değil: {v} +@msg `{k}.family` is required `{k}.family` gereklidir +@msg `{k}` is a number 0..1 `{k}` 0..1 aralığında bir sayıdır +@msg `{k}` is true or false `{k}` true veya false olur +@msg `{k}` isn't an export setting: {_1} `{k}` bir dışa aktarma ayarı değil: {_1} +@msg `{k}` must be a byte offset `{k}` bir bayt konumu olmalıdır +@msg `{k}` must be a number `{k}` bir sayı olmalıdır +@msg `{k}` must be a number of points `{k}` punto cinsinden bir sayı olmalıdır +@msg `{k}` must be a string `{k}` bir dize olmalıdır +@msg `{k}` must be a whole number `{k}` bir tam sayı olmalıdır +@msg `{k}` must be from {lo} to {hi} for {key} {key} için `{k}` {lo} ile {hi} arasında olmalıdır +@msg `{k}` must be {what}, not {v} `{k}` {what} olmalıdır, {v} değil +@msg `{l}` isn't a global or spot colour `{l}` genel veya spot bir renk değil +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` bir bitmap değil: clipboard.importSvg, importPdf veya importText kullanın +@msg `{name}` can't be deleted `{name}` silinemez +@msg `{name}` can't be duplicated `{name}` çoğaltılamaz +@msg `{name}` can't be edited `{name}` düzenlenemez +@msg `{name}` can't be edited this way `{name}` bu şekilde düzenlenemez +@msg `{name}` can't be renamed `{name}` yeniden adlandırılamaz +@msg `{name}` can't name a folder `{name}` bir klasörü belirtemez +@msg `{name}` has no art to place `{name}` içinde yerleştirilecek resim yok +@msg `{name}` has no text to place `{name}` içinde yerleştirilecek metin yok +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text Word belgesindeki `{name}`, UTF-8 veya UTF-16 metni değil +@msg `{name}` in the Word document is not well-formed XML: {e} Word belgesindeki `{name}` iyi biçimlendirilmiş bir XML değil: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep Word belgesindeki `{name}`, öğeleri {MAX_NESTING} düzeyden daha derin iç içe yerleştiriyor +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` yerleşik bir ön ayardır ve salt okunurdur: başka bir adla kaydedin +@msg `{name}` is a built-in preset: save under another name `{name}` yerleşik bir ön ayardır: başka bir adla kaydedin +@msg `{name}` is a built-in profile and stays `{name}` yerleşik bir profildir ve kalır +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}`, `{base}` renginin bir tonudur: `{base}` örneğini düzenleyin (bir ton renk örneği yalnızca yeni bir ad alır) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` bir çalışma alanıdır: önce edit.colorSettings ile başka birini seçin +@msg `{name}` is built in and stays `{name}` yerleşiktir ve kalır +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` kendi türünde bir boya alır (renk, degrade veya desen) +@msg `{n}` can't be deleted `{n}` silinemez +@msg `{n}` can't be merged `{n}` birleştirilemez +@msg `{n}` can't be moved `{n}` taşınamaz +@msg `{n}` is not a colour swatch `{n}` bir renk örneği değil +@msg `{n}` isn't a solid colour `{n}` düz bir renk değil +@msg `{n}`: colour groups hold solid colours only `{n}`: renk grupları yalnızca düz renkler içerir +@msg `{other}` doesn't trace an image `{other}` bir görüntüyü izlemez +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: adımın başvuruları {_1} MB'tan fazla değer kopyalıyor +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` bir eksen etiketi değil: bir etiket dört karakterdir, örneğin "wght" +@msg `{to}` is empty or already used `{to}` boş veya zaten kullanılıyor +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` {MAX_TEXT} bayttan uzun +@msg `{what}` must be text `{what}` metin olmalıdır +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard bir DXF çiziminin tek bir başlangıç noktası vardır: çalışma yüzeyi başına bir çizim için useArtboards: true verin +@msg a PDF presets file with over {_1} presets {_1} taneden fazla ön ayar içeren bir PDF ön ayarları dosyası +@msg a batch preview cannot switch documents bir toplu önizleme belge değiştiremez +@msg a brush named `{nn}` exists `{nn}` adlı bir fırça var +@msg a container of the listed objects is gone listelenen nesnelerin bir kapsayıcısı artık yok +@msg a content stream can't be read bir içerik akışı okunamıyor +@msg a content stream isn't a stream bir içerik akışı bir akış değil +@msg a design holds at most {_1} objects bir tasarım en fazla {_1} nesne içerir +@msg a design needs a name bir tasarımın adı olmalıdır +@msg a dictionary grew too large bir sözlük çok büyüdü +@msg a document keeps at most {_1} graph designs bir belge en fazla {_1} grafik tasarımı tutar +@msg a document keeps at most {_1} saved selections bir belge en fazla {_1} kayıtlı seçim tutar +@msg a document keeps at most {_1} views bir belge en fazla {_1} görünüm tutar +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files bir belge en fazla {MAX_SVG_SCRIPTS} JavaScript dosyasına bağlanır +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes bir feConvolveMatrix filtresinin order değeri {x}×{y}; bu, usvg'nin yaptığı çekirdek boyutu çarpımını taşırıyor +@msg a gradient preset is already called {n} {n} adlı bir degrade ön ayarı zaten var +@msg a graph has at most {_1} series, so it transposes with at most that many categories bir grafikte en fazla {_1} seri bulunur, bu yüzden en fazla o kadar kategoriyle yer değiştirir +@msg a layer can't be merged into its own sublayer bir katman kendi alt katmanıyla birleştirilemez +@msg a layer's form box isn't an array bir katmanın form kutusu bir dizi değil +@msg a layer's form can't be read bir katmanın formu okunamıyor +@msg a layer's form has no box bir katmanın formunda kutu yok +@msg a layer's form has no dictionary bir katmanın formunda sözlük yok +@msg a legend draws at most {MAX_CHIPS} swatches bir gösterge en fazla {MAX_CHIPS} renk örneği çizer +@msg a name names one asset: give multiple: false, or one object bir ad tek bir varlığı belirtir: multiple: false veya tek bir nesne verin +@msg a page can't be read bir sayfa okunamıyor +@msg a page object is used by something else bir sayfa nesnesi başka bir şey tarafından kullanılıyor +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) bir sayfa küçük resmi, kenar başına 1 ile {THUMBNAIL_SIZE} arasında RGB pikseller olmalıdır (verilen: {w} × {h}) +@msg a pattern layer doesn't end what it starts bir desen katmanı başlattığını bitirmiyor +@msg a pattern layer's colors hold something else bir desen katmanının renkleri başka bir şey içeriyor +@msg a pattern named `{n}` exists `{n}` adlı bir desen var +@msg a plug-in with id {_1} is already installed {_1} kimlikli bir eklenti zaten kurulu +@msg a preset named `{n}` exists `{n}` adlı bir ön ayar var +@msg a print presets file with over {_1} presets {_1} taneden fazla ön ayar içeren bir yazdırma ön ayarları dosyası +@msg a profile is already called {n} {n} adlı bir profil zaten var +@msg a row can't go into itself bir satır kendi içine giremez +@msg a series goes on the left or the right value axis bir seri sol veya sağ değer eksenine yerleşir +@msg a start must be an anchor index ≥ 0 or null bir başlangıç, ≥ 0 olan bir bağlantı noktası dizini veya null olmalıdır +@msg a style named `{name}` already exists `{name}` adlı bir stil zaten var +@msg a style named `{to}` already exists `{to}` adlı bir stil zaten var +@msg a swatch needs a colour, gradient or pattern bir renk örneği için renk, degrade veya desen gerekir +@msg a symbol named `{n}` already exists `{n}` adlı bir sembol zaten var +@msg a theme has 1 to {THEME_SIZE} colours bir temada 1 ile {THEME_SIZE} arasında renk bulunur +@msg a theme is already called {name} (replace: "{name}" overwrites it) {name} adlı bir tema zaten var (replace: "{name}" üzerine yazar) +@msg a toolbar name has at most {MAX_NAME} characters bir araç çubuğu adı en fazla {MAX_NAME} karakter içerir +@msg a view named `{_1}` exists `{_1}` adlı bir görünüm var +@msg a view named `{name}` exists `{name}` adlı bir görünüm var +@msg a workspace keeps at most {MAX} toolbars bir çalışma alanı en fazla {MAX} araç çubuğu tutar +@msg a {_1} series can't go in a {_2} graph {_1} türünde bir seri {_2} türünde bir grafiğe konulamaz +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF uyumlu, varsayılan) veya "7" (eski .ai, sürüm 7) +@msg already editing an opacity mask zaten bir opaklık maskesi düzenleniyor +@msg already editing another object's opacity mask zaten başka bir nesnenin opaklık maskesi düzenleniyor +@msg already in pattern editing mode zaten desen düzenleme modunda +@msg an artboard is listed twice bir çalışma yüzeyi iki kez listelenmiş +@msg an empty artboard has no width or height to scale to boş bir çalışma yüzeyinin ölçeklenecek genişliği veya yüksekliği yoktur +@msg an image is too large for DXF and was left out bir görüntü DXF için çok büyük olduğundan dışarıda bırakıldı +@msg an image of {_1} MB, at most {_2} MB {_1} MB'lık bir görüntü, en fazla {_2} MB +@msg an image of {declared_w} × {declared_h} pixels is too large to draw {declared_w} × {declared_h} piksellik bir görüntü çizilemeyecek kadar büyük +@msg an image of {width} × {height} pixels is too large to draw {width} × {height} piksellik bir görüntü çizilemeyecek kadar büyük +@msg an index must be a whole number bir dizin tam sayı olmalıdır +@msg an inline graphic has no glyphs satır içi bir grafikte glif yok +@msg an invisible text form can't be read görünmez bir metin formu okunamıyor +@msg an object is missing bir nesne eksik +@msg an object's header is missing bir nesnenin üst bilgisi eksik +@msg an overprinting form can't be read üst baskılı bir form okunamıyor +@msg an unread object has no readable bounds okunmamış bir nesnenin okunabilir sınırları yok +@msg an unread object holds a frame of threaded type okunmamış bir nesne zincirli metnin bir çerçevesini içeriyor +@msg an unread object is missing from its layer okunmamış bir nesne katmanında eksik +@msg anchor `smooth` must be true or false bağlantı noktasının `smooth` değeri true veya false olmalıdır +@msg anchor `{key}` must be [x, y] bağlantı noktasının `{key}` değeri [x, y] olmalıdır +@msg anchor `{key}` must be a number bağlantı noktasının `{key}` değeri bir sayı olmalıdır +@msg angle and roundness must be finite açı ve yuvarlaklık sonlu olmalıdır +@msg angles must be a list as long as pins (degrees or null) angles, pins ile aynı uzunlukta bir liste olmalıdır (derece veya null) +@msg appearance must be flat|center|edge appearance flat|center|edge olmalıdır +@msg apply is "fill" or "stroke", not `{other}` apply "fill" veya "stroke" olur, `{other}` değil +@msg area must be a closed polygon of 3 to 100000 [x, y] points area, 3 ile 100000 arasında [x, y] noktasından oluşan kapalı bir çokgen olmalıdır +@msg area type needs a path that encloses an area alan metni için bir alanı çevreleyen bir yol gerekir +@msg area type {_1} is named twice alan metni {_1} iki kez belirtilmiş +@msg arrowAlign must be extend|tip, got {o} arrowAlign extend|tip olmalıdır, verilen: {o} +@msg arrowScale must be [start, end] arrowScale [start, end] olmalıdır +@msg artboard is a 0-based index artboard 0 tabanlı bir dizindir +@msg artboard must be a 0-based index artboard 0 tabanlı bir dizin olmalıdır +@msg artboard {i} doesn't print {i} numaralı çalışma yüzeyi yazdırılmıyor +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout {layout?, columns?, spacing?, rightToLeft?} olmalıdır +@msg artboardLayout.columns must be 1 or more artboardLayout.columns 1 veya daha büyük olmalıdır +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout gridByRow, gridByColumn, row veya column olmalıdır +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft true veya false olmalıdır +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing 0–{MAX_SPACING} pt olmalıdır +@msg artboards must be a non-empty list artboards boş olmayan bir liste olmalıdır +@msg artboards must be a number artboards bir sayı olmalıdır +@msg asset `{_1}` has no art to export `{_1}` varlığında dışa aktarılacak resim yok +@msg at most {MAX_KEYWORDS} keywords en fazla {MAX_KEYWORDS} anahtar sözcük +@msg at most {MAX_MATCHES} matches are kept en fazla {MAX_MATCHES} eşleşme tutulur +@msg at most {MAX_POINTS} points en fazla {MAX_POINTS} nokta +@msg at most {MAX_QUEUE} files en fazla {MAX_QUEUE} dosya +@msg at must be [x, y] at [x, y] olmalıdır +@msg at/rect lie outside the canvas at/rect tuvalin dışında kalıyor +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute size|leading|tracking|kerning|baselineShift olmalıdır +@msg attrs must be an object attrs bir nesne olmalıdır +@msg automation path rejected: the folder's name isn't valid text otomasyon yolu reddedildi: klasörün adı geçerli bir metin değil +@msg automation path rejected: {_1} isn't valid text otomasyon yolu reddedildi: {_1} geçerli bir metin değil +@msg automation path rejected: {why}: {path} otomasyon yolu reddedildi: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis "vertical", "horizontal" veya derece cinsinden bir açı olmalıdır, `{given}` değil +@msg backgroundContents must be transparent or white backgroundContents transparent veya white olmalıdır +@msg bad AES key hatalı AES anahtarı +@msg bad `runs`: {e} hatalı `runs`: {e} +@msg bad arrowScale hatalı arrowScale +@msg bad base64 hatalı base64 +@msg bad color {c} hatalı renk {c} +@msg bad colour hatalı renk +@msg bad colour in `colors` `colors` içinde hatalı renk +@msg bad colour {v} hatalı renk {v} +@msg bad colour {v} in groupColors groupColors içinde hatalı renk {v} +@msg bad cross-reference entry hatalı çapraz başvuru girdisi +@msg bad cross-reference table hatalı çapraz başvuru tablosu +@msg bad current colour {v} hatalı geçerli renk {v} +@msg bad dataBase64 hatalı dataBase64 +@msg bad edit hatalı düzenleme +@msg bad fill {v} hatalı dolgu {v} +@msg bad fit: {e} hatalı fit: {e} +@msg bad model {other} (keep, new or document) hatalı model {other} (keep, new veya document) +@msg bad new colour {v} hatalı yeni renk {v} +@msg bad object hatalı nesne +@msg bad object number hatalı nesne numarası +@msg bad offset hatalı konum +@msg bad path data: {e} hatalı yol verisi: {e} +@msg bad point {v}: expected [x, y] or [x, y, pressure] hatalı nokta {v}: [x, y] veya [x, y, pressure] bekleniyordu +@msg bad reference hatalı başvuru +@msg bad trailer size hatalı trailer boyutu +@msg bad {k} colour hatalı {k} rengi +@msg batches cannot nest toplu işlemler iç içe olamaz +@msg bind something to a variable first önce bir şeyi bir değişkene bağlayın +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed 0–{MAX_BLEED} pt olmalıdır: bir sayı, [top, bottom, left, right] veya {{top, bottom, left, right}} +@msg brush must be a name or null brush bir ad veya null olmalıdır +@msg by must be a number by bir sayı olmalıdır +@msg can't add page thumbnails: {why} sayfa küçük resimleri eklenemiyor: {why} +@msg can't add the editing data for other apps: {why} diğer uygulamalar için düzenleme verileri eklenemiyor: {why} +@msg can't edit the written PDF: {why} yazılan PDF düzenlenemiyor: {why} +@msg can't encrypt the PDF: {why} PDF şifrelenemiyor: {why} +@msg can't linearise the PDF: {why} PDF doğrusallaştırılamıyor: {why} +@msg can't open `{_1}`: {_2} `{_1}` açılamıyor: {_2} +@msg can't open `{_1}`: {e} `{_1}` açılamıyor: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) `{name}` açılamıyor: VectorCraft'ın okuduğu bir biçim değil (bkz. document.formats) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) `{name}` yerleştirilemiyor: VectorCraft'ın okuduğu bir biçim değil (bkz. document.formats) +@msg can't read back the written PDF: {e} yazılan PDF geri okunamıyor: {e} +@msg can't read this Word document: {why} bu Word belgesi okunamıyor: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp .{e} görüntüleri yazılamıyor: .png, .jpg, .gif, .webp, .tif veya .bmp kullanın +@msg can't write PDF layers, overprint or invisible text: {why} PDF katmanları, üst baskı veya görünmez metin yazılamıyor: {why} +@msg can't write the file's copyright: {why} dosyanın telif hakkı yazılamıyor: {why} +@msg can't write the file's modification date: {why} dosyanın değiştirilme tarihi yazılamıyor: {why} +@msg can't write the pages' user units: {why} sayfaların kullanıcı birimleri yazılamıyor: {why} +@msg cannot distort a zero-size or non-finite bounding box sıfır boyutlu veya sonlu olmayan bir sınırlayıcı kutu çarpıtılamaz +@msg cannot move an object into itself bir nesne kendi içine taşınamaz +@msg case must be upper|lower|title|sentence case upper|lower|title|sentence olmalıdır +@msg category `{c}`: one of {_1} kategori `{c}`: şunlardan biri: {_1} +@msg center must be [x, y] center [x, y] olmalıdır +@msg choose at least one artboard en az bir çalışma yüzeyi seçin +@msg choose at least one status en az bir durum seçin +@msg clip must be true or false clip true veya false olmalıdır +@msg clippingMask must be true or false clippingMask true veya false olmalıdır +@msg closed must be a boolean closed bir mantıksal değer olmalıdır +@msg collapsed must be true or false collapsed true veya false olmalıdır +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: “{profile}” adlı bir RGB veya CMYK profili yok +@msg colorMode must be rgb or cmyk, not `{m}` colorMode rgb veya cmyk olmalıdır, `{m}` değil +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel {own} (belgenin modu), grayscale veya bitmap olmalıdır +@msg colour groups can't go into colour groups renk grupları renk gruplarının içine konulamaz +@msg colour, mode and spot apply to solid-colour swatches only colour, mode ve spot yalnızca düz renkli renk örnekleri için geçerlidir +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read sütun {column}: `{cell}`, grafik veri dosyalarının bir `%` sütunudur ve bu dosyalar okunmaz +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable sütun {column}: `{cell}`, {_1} türünde bir değişken olan `{name}` değişkenini belirtiyor +@msg column {column}: `{cell}` names no variable sütun {column}: `{cell}` hiçbir değişkeni belirtmiyor +@msg column {column}: the variable name is longer than {MAX_NAME} characters sütun {column}: değişken adı {MAX_NAME} karakterden uzun +@msg column: true floats the whole column and takes no onto, above or below column: true tüm sütunu yüzdürür ve onto, above veya below almaz +@msg command must be a shape.* command command bir shape.* komutu olmalıdır +@msg commands[{i}]: missing `command` commands[{i}]: `command` eksik +@msg composer must be "singleLine" or "everyLine" composer "singleLine" veya "everyLine" olmalıdır +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes bileşik şekiller yolları, bileşik yolları, grupları, bileşik şekilleri, metni, karışımları ve zarfları alır +@msg compound {_1} must contain exactly one open path to join bileşik {_1}, birleştirilecek tam olarak bir açık yol içermelidir +@msg connect needs a path other than the one extended connect için uzatılan yoldan başka bir yol gerekir +@msg connect needs id connect için id gerekir +@msg connect path has no open end connect yolunun açık ucu yok +@msg copy needs path: the file to copy into the document's folder copy için path gerekir: belgenin klasörüne kopyalanacak dosya +@msg copy takes images only: relink placed documents where their files are copy yalnızca görüntüleri alır: yerleştirilmiş belgeleri dosyalarının bulunduğu yerde yeniden bağlayın +@msg copying a file isn't available on the web dosya kopyalama web'de kullanılamaz +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus unknown, copyrighted veya publicDomain olmalıdır +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl bir URL olmalıdır (boşluksuz, en fazla 2048 karakter) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners 4 adet [x, y] noktası olmalıdır (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item veya file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: yalnızca PDF sayfalarında art, trim, bleed ve media kutuları bulunur; "crop" veya "bounding" kullanın +@msg crop rectangle doesn't overlap the image kırpma dikdörtgeni görüntüyle örtüşmüyor +@msg cross-reference and object streams aren't supported çapraz başvuru ve nesne akışları desteklenmiyor +@msg damaged compressed data: {e} hasarlı sıkıştırılmış veri: {e} +@msg dash lengths must be numbers, got {v} kesik çizgi uzunlukları sayı olmalıdır, verilen: {v} +@msg dash must be a list of lengths or null, got {v} dash bir uzunluk listesi veya null olmalıdır, verilen: {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) veri anahtarı `{k}`: harfler, rakamlar, -, _ ve . (name veya vc-… olamaz) +@msg data must be an object of key: value, not {v} data bir key: value nesnesi olmalıdır, {v} değil +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare `{name}` veri kümesi, kütüphanenin bildirmediği `{var}` için bir değer içeriyor +@msg data set `{name}` holds two values for `{var}` `{name}` veri kümesi `{var}` için iki değer içeriyor +@msg data set `{name}` is declared twice `{name}` veri kümesi iki kez bildirilmiş +@msg data takes at most {MAX_DATA} keys at once data bir seferde en fazla {MAX_DATA} anahtar alır +@msg dataset `{name}` already exists `{name}` veri kümesi zaten var +@msg dataset `{to}` already exists `{to}` veri kümesi zaten var +@msg date `{d}`: YYYY-MM-DD tarih `{d}`: YYYY-MM-DD +@msg decoded data is too large kodu çözülen veri çok büyük +@msg define a variable first önce bir değişken tanımlayın +@msg detect must be true or false detect true veya false olmalıdır +@msg direct-select anchor points of a path bir yolun bağlantı noktalarını doğrudan seçin +@msg distance must be > 0 distance > 0 olmalıdır +@msg dx and dy give the arrow's direction dx ve dy okun yönünü verir +@msg dx and dy must be numbers dx ve dy sayı olmalıdır +@msg dxf fitTo must be [width, height], positive dxf fitTo pozitif [width, height] olmalıdır +@msg dxf must be an object of DXF import options dxf, DXF içe aktarma seçeneklerinden oluşan bir nesne olmalıdır +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale en fazla {_1} olan pozitif bir sayı olmalıdır, {s} değil +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: mm, cm, in veya pt gibi bir birim +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} her biçim bir {format, scale?, suffix?} nesnesidir +@msg each item needs an id her öğe için bir id gerekir +@msg each match needs `kind` her eşleşme için `kind` gerekir +@msg each match needs a `name` her eşleşme için bir `name` gerekir +@msg each of `fonts` is {family, style?}; 1 to 1000 of them `fonts` öğelerinin her biri {family, style?} biçimindedir; 1 ile 1000 arasında öğe +@msg each of `items` is {kind, item} `items` öğelerinin her biri {kind, item} biçimindedir +@msg each of `items` is {{id, corners?}} with an object id, not {it} `items` öğelerinin her biri nesne kimliği içeren {{id, corners?}} biçimindedir, {it} değil +@msg each row needs `from` her satır için `from` gerekir +@msg each stop needs a position her durak için bir konum gerekir +@msg each subpath needs an anchors array her alt yol için bir anchors dizisi gerekir +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType easeIn, easeOut, easeInOut veya easeOutIn olmalıdır +@msg editingData must be true or false editingData true veya false olmalıdır +@msg editingData must be true, false or "always" editingData true, false veya "always" olmalıdır +@msg edits overlap düzenlemeler çakışıyor +@msg effects of objects whose appearances differ can't be moved together görünümleri farklı olan nesnelerin efektleri birlikte taşınamaz +@msg empty header in column {_1} {_1} sütununda boş başlık +@msg encoding `{_1}`: utf8 or utf16 encoding `{_1}`: utf8 veya utf16 +@msg ends must be two of "first" and "last" ends, "first" ve "last" değerlerinden iki tane olmalıdır +@msg every numbered name for the result is taken sonuç için numaralı adların tümü kullanılıyor +@msg expanded SVG styles exceed the import size limit genişletilmiş SVG stilleri içe aktarma boyut sınırını aşıyor +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes dışa aktarma ön ayarları dosyası {MAX_PRESET_FILE_BYTES} bayttan büyük +@msg exportText must be editable or appearance exportText editable veya appearance olmalıdır +@msg exportType must be movieClip or graphic, not `{s}` exportType movieClip veya graphic olmalıdır, `{s}` değil +@msg extend needs id extend için id gerekir +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges en fazla {MAX_FEATURE_CHANGES} etiket listeler +@msg featureChanges must be a list of tags featureChanges bir etiket listesi olmalıdır +@msg features must be a list of tags features bir etiket listesi olmalıdır +@msg fidelity must be 0..100 fidelity 0..100 olmalıdır +@msg files must be {name, dataBase64} files {name, dataBase64} olmalıdır +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit none|autoHeight|shrinkText|block olmalıdır, verilen: {id} +@msg flattener: {e} düzleştirici: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low veya kayıtlı bir ön ayar (bkz. flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile along|across olmalıdır, verilen: {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item veya itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels {MAX_BATCH_DEPTH} düzeyden daha derin klasörler +@msg folders deeper than {MAX_DEPTH} levels {MAX_DEPTH} düzeyden daha derin klasörler +@msg font family `{family}` is not available `{family}` yazı tipi ailesi kullanılamıyor +@msg font family `{tf}` is not available `{tf}` yazı tipi ailesi kullanılamıyor +@msg format `{f}`: pdf or postscript format `{f}`: pdf veya postscript +@msg format `{f}`: text or json format `{f}`: text veya json +@msg format is `{format}` format `{format}` +@msg formats must be an array of rows, not {v} formats bir satır dizisi olmalıdır, {v} değil +@msg frequency must be between 1 and 300 lines per inch frequency inç başına 1 ile 300 çizgi arasında olmalıdır +@msg from and to must be finite from ve to sonlu olmalıdır +@msg from must be [x0, y0, x1, y1] from [x0, y0, x1, y1] olmalıdır +@msg give `before` or `after`, not both `before` veya `after` verin, ikisini birden değil +@msg give `colors`, or `color` and `rule` `colors` verin ya da `color` ve `rule` verin +@msg give `event`, such as onclick onclick gibi bir `event` verin +@msg give `file`, the JavaScript file's path or URL JavaScript dosyasının yolu veya URL'si olan `file` değerini verin +@msg give `fill` and/or `stroke` `fill` ve/veya `stroke` verin +@msg give `fill`, `stroke` or both `fill`, `stroke` veya her ikisini verin +@msg give `folder` `folder` verin +@msg give `fonts`, or open a document `fonts` verin veya bir belge açın +@msg give `from` or `indices`, not both `from` veya `indices` verin, ikisini birden değil +@msg give `id` (no text is being edited) `id` verin (düzenlenen bir metin yok) +@msg give `id` and `anchors`, or `items`, not both `id` ve `anchors` ya da `items` verin, ikisini birden değil +@msg give `id` or `guide`, not both `id` veya `guide` verin, ikisini birden değil +@msg give `length` or `to` `length` veya `to` verin +@msg give `matches`: [{kind, name, target}] `matches` verin: [{kind, name, target}] +@msg give `name` (or edit a pattern) `name` verin (veya bir deseni düzenleyin) +@msg give `name` or `names` `name` veya `names` verin +@msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (bir .wasm dosyası) veya `dataBase64` (modül) verin +@msg give `path`, `data` or `dataBase64` `path`, `data` veya `dataBase64` verin +@msg give `path`, `data`, `dataBase64` or `csv` `path`, `data`, `dataBase64` veya `csv` verin +@msg give `path`, `xml` or `dataBase64` `path`, `xml` veya `dataBase64` verin +@msg give `script`, the JavaScript the event runs ("" removes the event) olayın çalıştırdığı JavaScript olan `script` değerini verin ("" olayı kaldırır) +@msg give `text` (the reading) or `remove` `text` (okunuş) veya `remove` verin +@msg give `x` and `y` (and a `tolerance` of 0 or more) `x` ve `y` verin (ve 0 veya daha büyük bir `tolerance`) +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments bir mod ya da r/g/b, c/m/y/k, gray veya tint ayarlamaları verin +@msg give a name, or paint the active proxy with a saved preset bir ad verin veya etkin vekili kayıtlı bir ön ayarla boyayın +@msg give a name, or select a stroke with a saved profile bir ad verin veya kayıtlı profili olan bir kontur seçin +@msg give a pattern name or select an object painted with a pattern bir desen adı verin veya desenle boyanmış bir nesne seçin +@msg give at least one of clip, invert, disabled, linked şunlardan en az birini verin: clip, invert, disabled, linked +@msg give color, none, swatch or gradient color, none, swatch veya gradient verin +@msg give dataBase64 dataBase64 verin +@msg give either at (a click) or area (a path drawn around the overlaps) at (bir tıklama) veya area (çakışmaların çevresine çizilen bir yol) verin +@msg give formats or a preset, not both formats veya bir ön ayar verin, ikisini birden değil +@msg give newName or delete newName veya delete verin +@msg give offset or by offset veya by verin +@msg give one of `to`, `rect` or `at` `to`, `rect` veya `at` değerlerinden birini verin +@msg give one of assets, layers, selection or fullDocument assets, layers, selection veya fullDocument değerlerinden birini verin +@msg give only one of onto, above, below, icon and a column place onto, above, below, icon ve bir sütun yerinden yalnızca birini verin +@msg give origin: [x, y], or reset: true origin: [x, y] veya reset: true verin +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note overprintFill, overprintStroke, showCenter, imageMap, url veya note verin +@msg give path (a file) or folder path (bir dosya) veya folder verin +@msg give path, or name and dataBase64 path ya da name ve dataBase64 verin +@msg give paths or files paths veya files verin +@msg give rows, rowHeight, columns or columnWidth rows, rowHeight, columns veya columnWidth verin +@msg give svg, or dataBase64 of an SVG or SVGZ file svg ya da bir SVG veya SVGZ dosyasının dataBase64 değerini verin +@msg give text text verin +@msg give the asset a name varlığa bir ad verin +@msg give the preset a `name` ön ayara bir `name` verin +@msg give the preset a name ön ayara bir ad verin +@msg give the preset a name (1–64 characters) ön ayara bir ad verin (1–64 karakter) +@msg give the settings to change, e.g. {"formats"}]} değiştirilecek ayarları verin, örneğin {"formats"}]} +@msg give the style a name stile bir ad verin +@msg give two or more style `names` iki veya daha fazla stil için `names` verin +@msg give two or more swatches iki veya daha fazla renk örneği verin +@msg give x, y, or a handle's length or angle x, y ya da bir tutamacın uzunluğunu veya açısını verin +@msg give {key, value} or {values} {key, value} veya {values} verin +@msg global must be true or false global true veya false olmalıdır +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) {_1} glifi, karakterin stiliyle (Tümü Büyük Harf, Küçük Büyük Harf veya bir rakam sistemi gibi) {ch} için çizilmiyor +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} {g} glifi, {_1} {_2} içinde {ch} karakterinin bir alternatifi değil +@msg gradient must be "objects" or "mesh" gradient "objects" veya "mesh" olmalıdır +@msg grays must be "profile" or "black", not `{g}` grays "profile" veya "black" olmalıdır, `{g}` değil +@msg gridColors must be a preset name or two colours gridColors bir ön ayar adı veya iki renk olmalıdır +@msg gridSize must be small, medium or large gridSize small, medium veya large olmalıdır +@msg guide {g} is not selected {g} numaralı kılavuz seçili değil +@msg handle must be 0..3 handle 0..3 olmalıdır +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (varsayılan), "omit" veya "visible" +@msg highlight a layer to merge into içine birleştirilecek bir katmanı vurgulayın +@msg highlight two or more rows in one layer or group tek bir katman veya gruptaki iki veya daha fazla satırı vurgulayın +@msg highlight two or more rows to merge birleştirmek için iki veya daha fazla satırı vurgulayın +@msg icon docks a panel in the icon column: use window.panel.dock icon bir paneli simge sütununa yerleştirir: window.panel.dock kullanın +@msg id must be a dimension id id bir boyut kimliği olmalıdır +@msg image has no bounds görüntünün sınırları yok +@msg image {_1} is linked already {_1} görüntüsü zaten bağlantılı +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client veya server +@msg index out of range dizin aralık dışında +@msg instances must be 1..1000 instances 1..1000 olmalıdır +@msg invalid `color` geçersiz `color` +@msg invalid anchor index {v}: expected a non-negative integer geçersiz bağlantı noktası dizini {v}: negatif olmayan bir tam sayı bekleniyordu +@msg invalid anchor reference {v}: expected [subpath, anchor] geçersiz bağlantı noktası başvurusu {v}: [subpath, anchor] bekleniyordu +@msg invalid angle geçersiz açı +@msg invalid brush: {e} geçersiz fırça: {e} +@msg invalid color geçersiz renk +@msg invalid colour {c} geçersiz renk {c} +@msg invalid delta geçersiz delta +@msg invalid point geçersiz nokta +@msg invalid size geçersiz boyut +@msg invalid t geçersiz t +@msg invalid tolerance geçersiz tolerans +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) çalışma yüzeylerinin tümü tuvalin dışında kalıyor (başlangıç noktasından {_1} pt'den uzak) +@msg its clipping masks are nested too deeply kırpma maskeleri çok derin iç içe geçmiş +@msg its metadata has no description meta verilerinde açıklama yok +@msg its metadata has no direct length meta verilerinde doğrudan bir uzunluk yok +@msg join two different paths iki farklı yolu birleştirin +@msg justify must be auto|left|center|right|justifyAll justify auto|left|center|right|justifyAll olmalıdır +@msg kbps must be a positive number, not {kbps} kbps pozitif bir sayı olmalıdır, {kbps} değil +@msg keywords must be a list of words or a comma-separated string keywords bir sözcük listesi veya virgülle ayrılmış bir dize olmalıdır +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters anahtar sözcükler en fazla {MAX_KEYWORD} karakter olmalı ve denetim karakteri içermemelidir +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind 1, 2 veya 3 olmalıdır ya da bir ön ayar için `name` verin (bkz. 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 veya paraStyle olmalıdır +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout "on", "off", "neutral" veya bir mantıksal değer olmalıdır, {v} değil +@msg layer `{name}` has no art to export `{name}` katmanında dışa aktarılacak resim yok +@msg layer must name a layer or sublayer layer bir katmanı veya alt katmanı belirtmelidir +@msg layers can't be intertwined: select the objects in them katmanlar iç içe geçirilemez: içlerindeki nesneleri seçin +@msg layers go only at the top level or in layers, objects in layers and groups katmanlar yalnızca en üst düzeye veya katmanların içine, nesneler ise katmanların ve grupların içine yerleşir +@msg layers must be true or false layers true veya false olmalıdır +@msg layout must be gridByRow, gridByColumn, row or column layout gridByRow, gridByColumn, row veya column olmalıdır +@msg leading must be a number or "auto" leading bir sayı veya "auto" olmalıdır +@msg left/right must be non-negative widths left/right negatif olmayan genişlikler olmalıdır +@msg level `{t}`: 2 or 3 level `{t}`: 2 veya 3 +@msg line {line}: more than {MAX_CSV_COLS} columns satır {line}: {MAX_CSV_COLS} taneden fazla sütun +@msg line {line}: unclosed quote satır {line}: kapatılmamış tırnak +@msg line {line}: value for `{var}` must match its variable kind satır {line}: `{var}` değeri değişkeninin türüyle eşleşmelidir +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf veya crlf +@msg loading profiles from disk isn't available in the browser diskten profil yükleme tarayıcıda kullanılamaz +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: {m} pt'lik bir kenar boşluğu kâğıtta yazdırılabilir alan bırakmıyor +@msg matrix must be finite and invertible, depth finite matrix sonlu ve tersinir, depth ise sonlu olmalıdır +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member; order değeri front, forward, backward veya back olduğunda kullanılır +@msg missing `artboards` `artboards` eksik +@msg missing `at` [x, y] `at` [x, y] eksik +@msg missing `attrs` `attrs` eksik +@msg missing `color` or `opacity` `color` veya `opacity` eksik +@msg missing `colors` `colors` eksik +@msg missing `commands` `commands` eksik +@msg missing `destinationFolder` `destinationFolder` eksik +@msg missing `effect` id `effect` kimliği eksik +@msg missing `family` `family` eksik +@msg missing `find` `find` eksik +@msg missing `gid` (a glyph id, or null for the default glyph) `gid` eksik (bir glif kimliği veya varsayılan glif için null) +@msg missing `group` `group` eksik +@msg missing `id` `id` eksik +@msg missing `id`: give one, or select one type object `id` eksik: bir tane verin veya tek bir metin nesnesi seçin +@msg missing `index` (a point index, or null to clear) `index` eksik (bir nokta dizini veya temizlemek için null) +@msg missing `index` (a stop index, or null to clear) `index` eksik (bir durak dizini veya temizlemek için null) +@msg missing `index` (an item index or null) `index` eksik (bir öğe dizini veya null) +@msg missing `item` `item` eksik +@msg missing `library` `library` eksik +@msg missing `library` (see graphicStyle.libraries) `library` eksik (bkz. graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) `library` eksik (bkz. swatch.library.list) +@msg missing `map` (rows [{from, to}] or {current}) `map` eksik (satırlar [{from, to}] veya {current}) +@msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] eksik +@msg missing `name` (see gradient.presets.list) `name` eksik (bkz. gradient.presets.list) +@msg missing `offset` (a distance in points) `offset` eksik (punto cinsinden bir uzaklık) +@msg missing `path` `path` eksik +@msg missing `path` (a path object id) `path` eksik (bir yol nesnesi kimliği) +@msg missing `rule` `rule` eksik +@msg missing `source` id `source` kimliği eksik +@msg missing `start` (the byte offset of a character) `start` eksik (bir karakterin bayt konumu) +@msg missing `status`: linked, embedded, missing or modified `status` eksik: linked, embedded, missing veya modified +@msg missing `target` id `target` kimliği eksik +@msg missing `text` `text` eksik +@msg missing `to` `to` eksik +@msg missing `to` (an index) `to` eksik (bir dizin) +@msg missing `to`: a non-negative integer `to` eksik: negatif olmayan bir tam sayı +@msg missing `type` `type` eksik +@msg missing `value` `value` eksik +@msg missing `{key}` `{key}` eksik +@msg missing `{k}` `{k}` eksik +@msg missing anchor bağlantı noktası eksik +@msg missing anchors bağlantı noktaları eksik +@msg missing anchors [[subpath, anchor]…] anchors [[subpath, anchor]…] eksik +@msg missing array `{key}` `{key}` dizisi eksik +@msg missing case case eksik +@msg missing color renk eksik +@msg missing commands komutlar eksik +@msg missing destination folder hedef klasör eksik +@msg missing from from eksik +@msg missing group grup eksik +@msg missing id or ids id veya ids eksik +@msg missing ids ids eksik +@msg missing indexes indexes eksik +@msg missing matrix [a,b,c,d,e,f] matrix [a,b,c,d,e,f] eksik +@msg missing name ad eksik +@msg missing op op eksik +@msg missing or invalid `color` `color` eksik veya geçersiz +@msg missing or unknown `tool` `tool` eksik veya bilinmiyor +@msg missing point nokta eksik +@msg missing point [x,y] [x,y] noktası eksik +@msg missing point `{key}` ([x, y]) `{key}` noktası eksik ([x, y]) +@msg missing points noktalar eksik +@msg missing points [[x,y]…] points [[x,y]…] eksik +@msg missing segment parça eksik +@msg missing stops duraklar eksik +@msg missing subpaths alt yollar eksik +@msg missing target hedef eksik +@msg missing to to eksik +@msg missing x, y x, y eksik +@msg mode is set, add or toggle, not `{other}` mode set, add veya toggle olur, `{other}` değil +@msg mode must be "cmyk" or "rgb" mode "cmyk" veya "rgb" olmalıdır +@msg mode must be area|onPath, got {o} mode area|onPath olmalıdır, verilen: {o} +@msg more than 10000 linked files: package dependencies in smaller parts 10000 taneden fazla bağlantılı dosya: bağımlılıkları daha küçük parçalar hâlinde paketleyin +@msg more than 256 commands 256 taneden fazla komut +@msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES} taneden fazla veri kümesi +@msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES} taneden fazla dosya +@msg more than {MAX_CSV_ROWS} data rows {MAX_CSV_ROWS} taneden fazla veri satırı +@msg more than {MAX_FILES} images {MAX_FILES} taneden fazla görüntü +@msg more than {MAX_IMPORT} presets {MAX_IMPORT} taneden fazla ön ayar +@msg moved must be [[x,y]…] moved [[x,y]…] olmalıdır +@msg name (a toolbar's name) is required name (bir araç çubuğunun adı) gereklidir +@msg name (a toolbar's name) must be a string name (bir araç çubuğunun adı) bir dize olmalıdır +@msg name must be a toolbar's name name bir araç çubuğunun adı olmalıdır +@msg names is empty names boş +@msg names must be an array of symbol names names bir sembol adları dizisi olmalıdır +@msg need `anchor` or `segment` + `t` `anchor` veya `segment` + `t` gerekir +@msg need at least one anchor en az bir bağlantı noktası gerekir +@msg need at least one point en az bir nokta gerekir +@msg need at least two points en az iki nokta gerekir +@msg needs points noktalar gerekir +@msg nineSlice must be true or false nineSlice true veya false olmalıdır +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides, her biri -16..16 aralığında, left + right < 1 ve top + bottom < 1 olacak şekilde [left, top, right, bottom] olmalıdır +@msg no PDF preset named `{n}` `{n}` adlı bir PDF ön ayarı yok +@msg no action set is current geçerli bir eylem seti yok +@msg no active data set etkin veri kümesi yok +@msg no appearance item {index} {index} numaralı görünüm öğesi yok +@msg no artboard {_1} {_1} numaralı çalışma yüzeyi yok +@msg no artboard {artboard} (0-based; the document has {_1}) {artboard} numaralı çalışma yüzeyi yok (0 tabanlı; belgede {_1} tane var) +@msg no asset {id} {id} kimlikli varlık yok +@msg no asset {id} (see assets.list) {id} kimlikli varlık yok (bkz. assets.list) +@msg no assets to export dışa aktarılacak varlık yok +@msg no assets yet: collect art for export first (assets.add) henüz varlık yok: önce dışa aktarılacak resmi toplayın (assets.add) +@msg no brush named `{n}` `{n}` adlı bir fırça yok +@msg no character at byte {start} {start} numaralı baytta karakter yok +@msg no colour renk yok +@msg no colour group `{group}` `{group}` renk grubu yok +@msg no colour group `{name}` `{name}` renk grubu yok +@msg no data sets veri kümesi yok +@msg no dataset named `{from}` `{from}` adlı bir veri kümesi yok +@msg no dataset named `{name}` `{name}` adlı bir veri kümesi yok +@msg no datasets veri kümesi yok +@msg no design named `{name}` `{name}` adlı bir tasarım yok +@msg no design named `{n}` `{n}` adlı bir tasarım yok +@msg no design named `{old}` `{old}` adlı bir tasarım yok +@msg no encoder for {_1} yet {_1} için henüz bir kodlayıcı yok +@msg no export preset `{name}` (see document.exportPresets.list) `{name}` adında dışa aktarma ön ayarı yok (bkz. document.exportPresets.list) +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder klasör yok: `path` verin veya Tercihler › Eklentiler ve Çalışma Diskleri › Ek Eklentiler Klasörü ayarını yapın +@msg no fonts to look for: they are all available aranacak yazı tipi yok: tümü kullanılabilir +@msg no formats to export dışa aktarılacak biçim yok +@msg no gradient preset `{name}` (see gradient.presets.list) `{name}` degrade ön ayarı yok (bkz. gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` `{_1}` içinde `{n}` grafik stili yok +@msg no graphic style library `{key}` (see graphicStyle.libraries) `{key}` grafik stili kütüphanesi yok (bkz. graphicStyle.libraries) +@msg no guide selected seçili kılavuz yok +@msg no guide selected (or give an index) seçili kılavuz yok (veya bir dizin verin) +@msg no guide {i} {i} numaralı kılavuz yok +@msg no images in the folder klasörde görüntü yok +@msg no item at index {_1} {_1} dizininde öğe yok +@msg no item at index {from} {from} dizininde öğe yok +@msg no item at one of the indices {idx} {idx} dizinlerinden birinde öğe yok +@msg no layer katman yok +@msg no layer {id} (see layer.list) {id} kimlikli katman yok (bkz. layer.list) +@msg no layers to export dışa aktarılacak katman yok +@msg no library `{key}` `{key}` kütüphanesi yok +@msg no metafile format meta dosya biçimi yok +@msg no object {_1} {_1} nesnesi yok +@msg no objects nesne yok +@msg no parent layer üst katman yok +@msg no perspective grid preset named `{b}` (see perspective.presets.list) `{b}` adlı bir perspektif ızgarası ön ayarı yok (bkz. perspective.presets.list) +@msg no perspective grid preset named `{n}` `{n}` adlı bir perspektif ızgarası ön ayarı yok +@msg no point selected (give `index`) seçili nokta yok (`index` verin) +@msg no preset called `{from}` (see file.newPresets) `{from}` adlı bir ön ayar yok (bkz. file.newPresets) +@msg no preset called `{name}` (see file.newPresets) `{name}` adlı bir ön ayar yok (bkz. file.newPresets) +@msg no preset named `{n}` `{n}` adlı bir ön ayar yok +@msg no print preset named `{b}` (see print.presets.list) `{b}` adlı bir yazdırma ön ayarı yok (bkz. print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) `{name}` adlı bir yazdırma ön ayarı yok (ön ayarlar: {_1}) +@msg no print preset named `{n}` `{n}` adlı bir yazdırma ön ayarı yok +@msg no profile named `{name}` `{name}` adlı bir profil yok +@msg no readable files in the folder klasörde okunabilir dosya yok +@msg no recovery copy `{f}` (see file.recovery.list) `{f}` kurtarma kopyası yok (bkz. file.recovery.list) +@msg no rows satır yok +@msg no saved PDF preset named `{name}` `{name}` adlı kayıtlı bir PDF ön ayarı yok +@msg no saved export preset `{name}` `{name}` adlı kayıtlı bir dışa aktarma ön ayarı yok +@msg no saved gradient preset called {name} {name} adlı kayıtlı bir degrade ön ayarı yok +@msg no saved gradient presets kayıtlı degrade ön ayarı yok +@msg no saved preset called `{name}` `{name}` adlı kayıtlı bir ön ayar yok +@msg no saved presets to export (name [Default] in `names` to export it) dışa aktarılacak kayıtlı ön ayar yok ([Default] ön ayarını dışa aktarmak için `names` içinde belirtin) +@msg no saved presets to export (name built-in ones in `names`) dışa aktarılacak kayıtlı ön ayar yok (yerleşik olanları `names` içinde belirtin) +@msg no saved print preset named `{name}` `{name}` adlı kayıtlı bir yazdırma ön ayarı yok +@msg no saved profile called {name} {name} adlı kayıtlı bir profil yok +@msg no saved selection `{name}` `{name}` adlı kayıtlı bir seçim yok +@msg no saved theme called {name} (see colorTheme.list) {name} adlı kayıtlı bir tema yok (bkz. colorTheme.list) +@msg no saved views kayıtlı görünüm yok +@msg no series {i} {i} numaralı seri yok +@msg no slice {n} in the image (see slice.list) görüntüde {n} numaralı dilim yok (bkz. slice.list) +@msg no source art kaynak resim yok +@msg no stop {i} (the gradient has {n}) {i} numaralı durak yok (degradede {n} tane var) +@msg no style `{name}` `{name}` stili yok +@msg no such assets (see assets.list) böyle varlıklar yok (bkz. assets.list) +@msg no such frame anchor, or the frame would reach past the canvas böyle bir çerçeve bağlantı noktası yok veya çerçeve tuvalin dışına taşardı +@msg no such objects böyle nesneler yok +@msg no such path anchor, or the path would reach past the canvas böyle bir yol bağlantı noktası yok veya yol tuvalin dışına taşardı +@msg no such rows böyle satırlar yok +@msg no swatch `{keep}` `{keep}` renk örneği yok +@msg no swatch `{n}` `{n}` renk örneği yok +@msg no swatch `{n}` (swatches and colour groups move separately) `{n}` renk örneği yok (renk örnekleri ve renk grupları ayrı ayrı taşınır) +@msg no swatch library `{key}` (see swatch.library.list) `{key}` renk örneği kütüphanesi yok (bkz. swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) `{key}` renk örneği kütüphanesi yok (bkz. swatch.library.list; "document": belgenin renk örnekleri) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) `{key}` renk örneği kütüphanesi yok (bkz. swatch.library.list; "{DOCUMENT_SWATCHES}": belgenin renk örnekleri) +@msg no swatch named `{n}` `{n}` adlı bir renk örneği yok +@msg no swatch or colour group `{n}` `{n}` adlı bir renk örneği veya renk grubu yok +@msg no swatch or colour group `{n}` in `{_1}` `{_1}` içinde `{n}` adlı bir renk örneği veya renk grubu yok +@msg no symbol named `{name}` `{name}` adlı bir sembol yok +@msg no text objects selected seçili metin nesnesi yok +@msg no toolbar named `{_1}` `{_1}` adlı bir araç çubuğu yok +@msg no toolbar named `{name}` `{name}` adlı bir araç çubuğu yok +@msg no tracing preset `{preset}` (see imageTrace.presets) `{preset}` izleme ön ayarı yok (bkz. imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) burada kullanıcı kütüphanesi klasörü yok (bir yolla kaydedin veya verileri almak için yol vermeden kaydedin) +@msg no variable named `{from}` `{from}` adlı bir değişken yok +@msg no variable named `{name}` `{name}` adlı bir değişken yok +@msg no variable named `{var}` `{var}` adlı bir değişken yok +@msg no variable named `{v}` `{v}` adlı bir değişken yok +@msg no view named `{name}` `{name}` adlı bir görünüm yok +@msg node {_1} is not a graph {_1} düğümü bir grafik değil +@msg node {_1} is not an image {_1} düğümü bir görüntü değil +@msg node {_1} is not type {_1} düğümü metin değil +@msg not a PDF presets file (format `{_1}`) bir PDF ön ayarları dosyası değil (biçim `{_1}`) +@msg not a PDF presets file: {e} bir PDF ön ayarları dosyası değil: {e} +@msg not a Word document: its WordDocument stream doesn't start with a file information block bir Word belgesi değil: WordDocument akışı bir dosya bilgi bloğuyla başlamıyor +@msg not a Word document: its compound file has no WordDocument stream bir Word belgesi değil: bileşik dosyasında WordDocument akışı yok +@msg not a Word document: its main part is not a WordprocessingML document bir Word belgesi değil: ana bölümü bir WordprocessingML belgesi değil +@msg not a Word document: its package has no main document part bir Word belgesi değil: paketinde ana belge bölümü yok +@msg not a closed path kapalı bir yol değil +@msg not a flattener presets file (format `{_1}`) bir düzleştirici ön ayarları dosyası değil (biçim `{_1}`) +@msg not a flattener presets file: {e} bir düzleştirici ön ayarları dosyası değil: {e} +@msg not a layer or group bir katman veya grup değil +@msg not a library file: {e} bir kütüphane dosyası değil: {e} +@msg not a mesh envelope point bir ağ zarfı noktası değil +@msg not a perspective grid presets file (format `{_1}`) bir perspektif ızgarası ön ayarları dosyası değil (biçim `{_1}`) +@msg not a perspective grid presets file: {e} bir perspektif ızgarası ön ayarları dosyası değil: {e} +@msg not a print presets file (format `{_1}`) bir yazdırma ön ayarları dosyası değil (biçim `{_1}`) +@msg not a print presets file: {e} bir yazdırma ön ayarları dosyası değil: {e} +@msg not a stored or deflated central record saklanmış veya deflate ile sıkıştırılmış bir merkezi kayıt değil +@msg not a valid SVGZ file: {e} geçerli bir SVGZ dosyası değil: {e} +@msg not an image VectorCraft reads (see document.formats) VectorCraft'ın okuduğu bir görüntü değil (bkz. document.formats) +@msg not area type (text in a frame) outside perspective perspektif dışında alan metni (çerçeve içindeki metin) değil +@msg not editing an opacity mask bir opaklık maskesi düzenlenmiyor +@msg not type on a path outside perspective perspektif dışında yol üzerindeki yazı değil +@msg nothing prints hiçbir şey yazdırılmıyor +@msg nothing to bind bağlanacak bir şey yok +@msg nothing to change değiştirilecek bir şey yok +@msg nothing to change: give dx, dy, hScale, vScale or rotation değiştirilecek bir şey yok: dx, dy, hScale, vScale veya rotation verin +@msg nothing to crop kırpılacak bir şey yok +@msg nothing to export dışa aktarılacak bir şey yok +@msg nothing to export: the document has no visible art dışa aktarılacak bir şey yok: belgede görünür resim yok +@msg nothing to mark işaretlenecek bir şey yok +@msg nothing to print: every artboard picked is blank yazdırılacak bir şey yok: seçilen her çalışma yüzeyi boş +@msg nothing to print: the document has no art that prints yazdırılacak bir şey yok: belgede yazdırılan resim yok +@msg object is not a path nesne bir yol değil +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object nesne saydamlığı vurgulanan karakterlere örneklenemez; Damlalık Seçenekleri'nde Saydamlık seçeneğini kapatın veya nesnenin tamamını seçin +@msg object {_1} can't be set as point type {_1} nesnesi nokta metni olarak ayarlanamaz +@msg object {_1} has lost its file's contents: relink it {_1} nesnesi dosyasının içeriğini kaybetti: yeniden bağlayın +@msg object {_1} has no path {_1} nesnesinin yolu yok +@msg object {_1} has no size {_1} nesnesinin boyutu yok +@msg object {_1} is hidden or locked, or on a locked artboard {_1} nesnesi gizli veya kilitli ya da kilitli bir çalışma yüzeyinde +@msg object {_1} is not a placed document {_1} nesnesi yerleştirilmiş bir belge değil +@msg object {_1} is not an Intertwine group {_1} nesnesi bir İç İçe Geçme grubu değil +@msg object {_1} is not an image {_1} nesnesi bir görüntü değil +@msg object {_1} is not an image or a linked file {_1} nesnesi bir görüntü veya bağlantılı bir dosya değil +@msg object {_1} is not an image or a placed document {_1} nesnesi bir görüntü veya yerleştirilmiş bir belge değil +@msg object {_1} is not in this Intertwine group {_1} nesnesi bu İç İçe Geçme grubunda değil +@msg object {_1} needs a 3D effect at index {index} {_1} nesnesinin {index} dizininde bir 3D efekti olmalıdır +@msg object {id} has anchors 0–{_1} for `corners`, not {past} {id} nesnesinde `corners` için 0–{_1} bağlantı noktaları var, {past} yok +@msg object {id} has {n} anchors, so no anchor {past} {id} nesnesinde {n} bağlantı noktası var, bu yüzden {past} numaralı bağlantı noktası yok +@msg objects must be none, move or copy objects none, move veya copy olmalıdır +@msg objects were left out nesneler dışarıda bırakıldı +@msg offset out of range konum aralık dışında +@msg on must be true or false on true veya false olmalıdır +@msg only groups, layers, type and images have a Contents (Characters) row yalnızca grupların, katmanların, metnin ve görüntülerin bir İçerik (Karakterler) satırı vardır +@msg only interior mesh lines can be deleted yalnızca iç ağ çizgileri silinebilir +@msg only solid colours can be added (not gradients or patterns) yalnızca düz renkler eklenebilir (degradeler veya desenler eklenemez) +@msg only solid-colour swatches can be spot colours yalnızca düz renkli renk örnekleri spot renk olabilir +@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 bu Affinity dosyasının yalnızca gömülü önizlemesi okunabildi: uyarısıyla birlikte görmek için Dosya › Aç komutunu kullanın veya önce Affinity'den SVG ya da PDF olarak dışa aktarın +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning bu Affinity dosyasının yalnızca gömülü önizlemesi okunabildi; uyarısıyla birlikte görmek için Dosya › Aç komutunu kullanın +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon ve bir sütun yeri window.panel.float ve window.panel.dock komutlarına aittir +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon ve bir sütun yeri anchor, side, zone veya before almaz +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon ve bir sütun yeri x, y, width veya height almaz +@msg open a document to look for its linked files bağlantılı dosyalarını aramak için bir belge açın +@msg options must be an object (see file.formatOptions) options bir nesne olmalıdır (bkz. file.formatOptions) +@msg order must be leftToRight or rightToLeft order leftToRight veya rightToLeft olmalıdır +@msg orientation must be portrait or landscape, not `{o}` orientation portrait veya landscape olmalıdır, `{o}` değil +@msg outlineText {v}: true or false outlineText {v}: true veya false +@msg output intent profile: {e} çıktı amacı profili: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: “{name}” adlı bir RGB veya CMYK profili yok +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} dosyaları CMYK'dir ve “{name}” bir RGB profilidir +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: yazdırılmak üzere ayarlanmış mürekkep yok +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: “{name}” adlı bir profil yok ve bir {std} çıktı amacı profilini gömer +@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}” adlı bir profil yok: bir {std} çıktı amacı bir profil gömer veya kayıtlı bir baskı koşulunu belirtir (outputConditionId ve registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: {std} dosyaları CMYK olarak yazdırılır ve “{name}” bir RGB profilidir +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: “{name}” bir gri profildir ve bir {std} çıktı amacı olamaz +@msg overlap: {o} pt is half a tile or more overlap: {o} pt bir döşemenin yarısı veya daha fazlası +@msg overlapping Shaper paths must be in the same layer or group çakışan Şekillendirici yolları aynı katmanda veya grupta olmalıdır +@msg overlapping edits çakışan düzenlemeler +@msg page art conflicts with image data for {key} sayfa resmi {key} için görüntü verileriyle çakışıyor +@msg page art conflicts with pattern {name} sayfa resmi {name} deseniyle çakışıyor +@msg page art conflicts with swatch {name} sayfa resmi {name} renk örneğiyle çakışıyor +@msg page art is missing image data for {key} sayfa resminde {key} için görüntü verileri eksik +@msg page art is missing pattern {name} sayfa resminde {name} deseni eksik +@msg page art is missing swatch {name} sayfa resminde {name} renk örneği eksik +@msg page must be a whole number from 1 page 1'den başlayan bir tam sayı olmalıdır +@msg page thumbnail: {e} sayfa küçük resmi: {e} +@msg page {page} of the PDF has no objects PDF'nin {page} numaralı sayfasında nesne yok +@msg page {page}: `{file}` has {n} {artboards} sayfa {page}: `{file}` içinde {n} {artboards} var +@msg page {page}: `{name}` has {n} {pages} sayfa {page}: `{name}` içinde {n} {pages} var +@msg pages must be a range such as "2-3" or a page number, not {v} pages "2-3" gibi bir aralık veya bir sayfa numarası olmalıdır, {v} değil +@msg pages: {e} pages: {e} +@msg panel position must be finite panel konumu sonlu olmalıdır +@msg parameters must be an object parametreler bir nesne olmalıdır +@msg params must be an object params bir nesne olmalıdır +@msg passwords can have up to {MAX_PASSWORD} bytes parolalar en fazla {MAX_PASSWORD} bayt olabilir +@msg path has no open end yolun açık ucu yok +@msg paths must be strings paths dizelerden oluşmalıdır +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false, düzenlenebilir resmi korumak için her çalışma yüzeyine ihtiyaç duyar +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single veya multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom "artwork" veya "swatches" olur, {v} değil +@msg pins and moved must be non-empty lists of the same length pins ve moved aynı uzunlukta, boş olmayan listeler olmalıdır +@msg pins must be [[x,y]…] pins [[x,y]…] olmalıdır +@msg pins must be finite pins sonlu olmalıdır +@msg place is above, below or inside, not `{other}` place above, below veya inside olur, `{other}` değil +@msg plane must be left|right|ground plane left|right|ground olmalıdır +@msg plane must be left|right|ground|none plane left|right|ground|none olmalıdır +@msg point is outside the mesh nokta ağın dışında +@msg points must be [t, left, right] with non-negative widths points, negatif olmayan genişliklerle [t, left, right] olmalıdır +@msg points must be a non-empty [[x,y,pressure?]…] list points boş olmayan bir [[x,y,pressure?]…] listesi olmalıdır +@msg points must be a non-empty list of finite [x,y] points, sonlu [x,y] değerlerinden oluşan boş olmayan bir liste olmalıdır +@msg points must be an array or null points bir dizi veya null olmalıdır +@msg points must be finite points sonlu olmalıdır +@msg points must be finite [x,y] pairs points sonlu [x,y] çiftleri olmalıdır +@msg points must contain 1–4096 coordinates points 1–4096 koordinat içermelidir +@msg position must be normal, superscript or subscript position normal, superscript veya subscript olmalıdır +@msg position must be topLeft, topRight, bottomLeft or bottomRight position topLeft, topRight, bottomLeft veya bottomRight olmalıdır +@msg positions must be between 0 and 100000 pt konumlar 0 ile 100000 pt arasında olmalıdır +@msg ppi must be a positive number, not {ppi} ppi pozitif bir sayı olmalıdır, {ppi} değil +@msg ppi must be between 1 and 2400 ppi 1 ile 2400 arasında olmalıdır +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}`, transforms|bounds|fileDimensions|fit|fill değerlerinden biri değil +@msg preserveShape must be clippingMask|transparency preserveShape clippingMask|transparency olmalıdır +@msg preset `{id}`: mobile or density preset `{id}`: mobile veya density +@msg preset must be standard, diffuse, topLeft or right preset standard, diffuse, topLeft veya right olmalıdır +@msg preset must be the name of a print preset preset bir yazdırma ön ayarının adı olmalıdır +@msg previewMode must be default, pixel or overprint previewMode default, pixel veya overprint olmalıdır +@msg print settings: {e} yazdırma ayarları: {e} +@msg procedures nest too deeply yordamlar çok derin iç içe geçiyor +@msg procedures nested too deeply yordamlar çok derin iç içe geçmiş +@msg quotes must be {double?, single?} quotes {double?, single?} olmalıdır +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} bir açılış ve bir kapanış tırnağı olmalıdır, örneğin "“”" +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi 1–2400 olmalıdır (72 ekran, 150 orta, 300 yüksek) +@msg rating must be a whole number from 0 to {MAX_RATING} rating 0 ile {MAX_RATING} arasında bir tam sayı olmalıdır +@msg rect and {k} contradict each other: give the area or the artboards rect ve {k} birbiriyle çelişiyor: alanı veya çalışma yüzeylerini verin +@msg rect applies to raster formats, not {_1} rect raster biçimler için geçerlidir, {_1} için değil +@msg rect is [x, y, width, height] in points rect punto cinsinden [x, y, width, height] olur +@msg rect must be [x, y, width, height] rect [x, y, width, height] olmalıdır +@msg rect must be [x, y, width, height] with a positive width and height rect, pozitif genişlik ve yükseklikle [x, y, width, height] olmalıdır +@msg rect needs finite numbers and a positive width and height rect için sonlu sayılar ile pozitif bir genişlik ve yükseklik gerekir +@msg redraw needs id redraw için id gerekir +@msg redraw within must be a finite distance of 0 or more redraw within 0 veya daha büyük sonlu bir uzaklık olmalıdır +@msg registration must be 0..8 registration 0..8 olmalıdır +@msg release a layer or a group bir katmanı veya grubu serbest bırakın +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace, değiştirilen nesnenin yerini ve dönüşümünü korur: template, at ve rect değerlerini çıkarın +@msg replace: select the one object to replace replace: değiştirilecek tek nesneyi seçin +@msg resolution must be a number or screen|medium|high resolution bir sayı veya screen|medium|high olmalıdır +@msg resolution must be between 1 and 2400 ppi resolution 1 ile 2400 ppi arasında olmalıdır +@msg rotation must be a number of degrees rotation derece cinsinden bir sayı olmalıdır +@msg rotation must be finite rotation sonlu olmalıdır +@msg rows and cols must be 1..50 rows ve cols 1..50 olmalıdır +@msg rows and columns must be 1..500 rows ve columns 1..500 olmalıdır +@msg rows of objects whose appearances differ can't be reordered together görünümleri farklı olan nesnelerin satırları birlikte yeniden sıralanamaz +@msg save the document first: Package collects a saved document önce belgeyi kaydedin: Paket kayıtlı bir belgeyi toplar +@msg save the document first: copy puts the file in its folder önce belgeyi kaydedin: copy dosyayı belgenin klasörüne koyar +@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 gibi bir çarpan, bir genişlik (100w), bir yükseklik (100h) veya bir çözünürlük (72ppi) +@msg scale must be a number above 0, at most 64 scale 0'dan büyük, en fazla 64 olan bir sayı olmalıdır +@msg scale must be non-zero scale sıfırdan farklı olmalıdır +@msg scope must be selection or all (got {other}) scope selection veya all olmalıdır (verilen: {other}) +@msg scribble is too long karalama çok uzun +@msg segment does not exist parça yok +@msg select a Shaper group bir Şekillendirici grubu seçin +@msg select a blend to set easing or color shift yumuşatmayı veya renk kaymasını ayarlamak için bir karışım seçin +@msg select a compound shape bir bileşik şekil seçin +@msg select a graph bir grafik seçin +@msg select a group or layer with visible, unlocked members görünür ve kilitli olmayan üyeleri olan bir grup veya katman seçin +@msg select a layer bir katman seçin +@msg select a text object bir metin nesnesi seçin +@msg select an image (or an Image Trace group) bir görüntü (veya bir Görüntü İzleme grubu) seçin +@msg select an image, or pass id bir görüntü seçin veya id verin +@msg select an object (or pass `id`) bir nesne seçin (veya `id` verin) +@msg select an object with a freeform gradient serbest biçimli degradesi olan bir nesne seçin +@msg select an object with an opacity mask opaklık maskesi olan bir nesne seçin +@msg select area type alan metni seçin +@msg select area type (text in a frame) alan metni (çerçeve içindeki metin) seçin +@msg select area type and closed paths (at least one area type object) alan metni ve kapalı yollar seçin (en az bir alan metni nesnesi) +@msg select area type whose first line can fit its frame ilk satırı çerçevesine sığabilen alan metni seçin +@msg select artwork to add its colours renklerini eklemek için resim seçin +@msg select artwork to make a group of its colours renklerinden bir grup oluşturmak için resim seçin +@msg select artwork to recolour, or give a colour `group` yeniden renklendirilecek resmi seçin veya bir renk için `group` verin +@msg select exactly two path ends to join birleştirmek için tam olarak iki yol ucu seçin +@msg select members of a compound shape bir bileşik şeklin üyelerini seçin +@msg select objects or give ids nesneleri seçin veya ids verin +@msg select objects to align to the guide kılavuza hizalanacak nesneleri seçin +@msg select one Intertwine group tek bir İç İçe Geçme grubu seçin +@msg select one image or pass id tek bir görüntü seçin veya id verin +@msg select paths yolları seçin +@msg select paths or compound paths yolları veya bileşik yolları seçin +@msg select rows or objects to collect toplanacak satırları veya nesneleri seçin +@msg select something to export dışa aktarılacak bir şey seçin +@msg select something to export (or turn off selectedOnly) dışa aktarılacak bir şey seçin (veya selectedOnly seçeneğini kapatın) +@msg select text to redefine the style from stilin yeniden tanımlanacağı metni seçin +@msg select the art and, on top of it, the mask object resmi ve onun üstündeki maske nesnesini seçin +@msg select the art for the design tasarım için resmi seçin +@msg select the art for the pattern tile desen döşemesi için resmi seçin +@msg select the art to add first önce eklenecek resmi seçin +@msg select the art to collect (or give its ids) toplanacak resmi seçin (veya kimliklerini verin) +@msg select the content and a path on top içeriği ve üstteki bir yolu seçin +@msg select the images to relink, or pass ids yeniden bağlanacak görüntüleri seçin veya ids verin +@msg select the images, or pass ids görüntüleri seçin veya ids verin +@msg select the linked images or placed documents, or pass ids bağlantılı görüntüleri veya yerleştirilmiş belgeleri seçin ya da ids verin +@msg select the object to copy from kopyalanacak kaynak nesneyi seçin +@msg select the objects (or give their ids) nesneleri seçin (veya kimliklerini verin) +@msg select the objects to attach to the path yola iliştirilecek nesneleri seçin +@msg select the objects to bind bağlanacak nesneleri seçin +@msg select two or more objects to intertwine iç içe geçirmek için iki veya daha fazla nesne seçin +@msg select two or more slices to combine birleştirmek için iki veya daha fazla dilim seçin +@msg select type on a path yol üzerindeki yazıyı seçin +@msg selection `{m}`: one or each selection `{m}`: one veya each +@msg settings is an object of document.exportForScreens params settings, document.exportForScreens parametrelerinden oluşan bir nesnedir +@msg settings must be an object settings bir nesne olmalıdır +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified veya embedded +@msg show must be true or false show true veya false olmalıdır +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size "small", "medium", "large" veya 0'dan büyük, en fazla {_1} olan punto cinsinden bir sayı olmalıdır +@msg size must be positive size pozitif olmalıdır +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode "fixed" veya "pressure" olur, `{other}` değil +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode "fixed" veya "pressure" olur, {other} değil +@msg sizeVariation must be 0 or more sizeVariation 0 veya daha büyük olmalıdır +@msg smallCaps must be true or false smallCaps true veya false olmalıdır +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind veya status +@msg source paths are too large for Shaper kaynak yollar Şekillendirici için çok büyük +@msg spot, mode and group apply to solid colours only spot, mode ve group yalnızca düz renkler için geçerlidir +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing veya modified +@msg steps must be 1..1000 steps 1..1000 olmalıdır +@msg steps must be 1..{MAX_STEPS} steps 1..{MAX_STEPS} olmalıdır +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) durak {i}: `plates` 0 ile 1 arasında dört sayı olmalıdır (C, M, Y, K) +@msg stopped before the archive was finished arşiv tamamlanmadan önce durduruldu +@msg strokeMode must be within|along|across, got {m} strokeMode within|along|across olmalıdır, verilen: {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions, stroke.set seçeneklerinden oluşan bir nesne olmalıdır +@msg strokeWidth must be a positive number of pixels strokeWidth piksel cinsinden pozitif bir sayı olmalıdır +@msg subpath {si} is not an open subpath {si} numaralı alt yol açık bir alt yol değil +@msg subpaths required when joining the same path aynı yol birleştirilirken subpaths gereklidir +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict "merge" veya "add" olmalıdır, {v} değil +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} olmalıdır +@msg text transform is collapsed or unrepresentable metin dönüşümü çökmüş veya ifade edilemiyor +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces 2 ile 100 arasında bir tam sayı olmalıdır +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: "{_1}" veya "{_2}" kullanın +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: bir görüntü dosyasının {path} veya {name, dataBase64} değerini ya da effect.list'in verdiği biçimde bir doku verin +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices bu {MAX_PIECES} taneden fazla dilim oluşturuyor +@msg that object is not in the overlap o nesne çakışmanın içinde değil +@msg the Affinity document has no page with a size Affinity belgesinde boyutu olan bir sayfa yok +@msg the CMYK image couldn't be written CMYK görüntü yazılamadı +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected PDF güvenlik ayarları parolaları olmadan kaydedildi: openPassword veya permissionsPassword değerini yeniden verin ya da korumasız dışa aktarmak için passwordRequired olmadan security verin +@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 dosyasında metin olarak yerleştirilemeyecek kadar çok paragraf veya biçimlendirme değişikliği var (en fazla {MAX_PARAGRAPHS} paragraf ve {MAX_RUNS} dizi) +@msg the RTF file is too large to place as type (at most {_1} MB) RTF dosyası metin olarak yerleştirilemeyecek kadar büyük (en fazla {_1} MB) +@msg the SVG expands to more than {0} nested SVG images SVG, {0} taneden fazla iç içe SVG görüntüsüne genişliyor +@msg the SVG expands to more than {0} objects SVG, {0} taneden fazla nesneye genişliyor +@msg the SVG has no drawable objects SVG'de çizilebilir nesne yok +@msg the SVGZ file unpacks to more than {_1} MB SVGZ dosyası açılınca {_1} MB'ı aşıyor +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place Word belgesinde {MAX_PAGES} sayfadan fazla özellik var; yerleştirilemeyecek kadar çok +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place Word belgesinde {MAX_RUNS} taneden fazla karakter veya paragraf özelliği dizisi var; yerleştirilemeyecek kadar çok +@msg the Word document has more than {_1} MB of formatting properties, too many to place Word belgesinde {_1} MB'tan fazla biçimlendirme özelliği var; yerleştirilemeyecek kadar çok +@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 belgesinde metin olarak yerleştirilemeyecek kadar çok metin veya biçimlendirme var (en fazla {MAX_PARAGRAPHS} paragraf, {MAX_RUNS} dizi ve {_1} MB metin) +@msg the Word document has too much text to place as type (at most {_1} MB) Word belgesinde metin olarak yerleştirilemeyecek kadar çok metin var (en fazla {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it Word belgesi şifrelenmiş veya parolayla korunmuş; yerleştirmek için parolasız kaydedin +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) Word belgesi yerleştirilemeyecek kadar büyük ({name} akışı {_1} MB'tan fazla veri içeriyor) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off Word belgesi son olarak hızlı kaydetme ile kaydedilmiş ve VectorCraft bunu okumaz; hızlı kaydetme kapalıyken yeniden kaydedin +@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 belgesi Word 6.0 veya Word 95 ile kaydedilmiş ve VectorCraft bunu okumaz; Word 97-2003 belgesi olarak kaydedin +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place Word belgesinin metni {MAX_PIECES} taneden fazla parçadan oluşuyor; yerleştirilemeyecek kadar çok +@msg the action set ids are not a list eylem seti kimlikleri bir liste değil +@msg the active paint is not a gradient etkin boya bir degrade değil +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size çalışma yüzeyleri tuvale sığmıyor: daha az çalışma yüzeyi, daha az aralık veya daha küçük bir boyut kullanın +@msg the character's size can't be measured karakterin boyutu ölçülemiyor +@msg the compound shape is too complex to expand bileşik şekil genişletilemeyecek kadar karmaşık +@msg the container of the listed objects is gone listelenen nesnelerin kapsayıcısı artık yok +@msg the copy `{user_id}` can't be read as a library `{user_id}` kopyası bir kütüphane olarak okunamıyor +@msg the copy would leave the canvas kopya tuvalin dışına çıkardı +@msg the cut cannot be made on this art: {e} bu resimde kesim yapılamıyor: {e} +@msg the data isn't an EMF or WMF picture veri bir EMF veya WMF resmi değil +@msg the data isn't an EPS or PostScript picture veri bir EPS veya PostScript resmi değil +@msg the deferred paste is no longer available ertelenmiş yapıştırma artık kullanılamıyor +@msg the design is too big to draw at every data point of this graph tasarım bu grafiğin her veri noktasında çizilemeyecek kadar büyük +@msg the design is too big to draw in every column of this graph tasarım bu grafiğin her sütununda çizilemeyecek kadar büyük +@msg the dialog it was opened over is no longer open üzerinde açıldığı iletişim kutusu artık açık değil +@msg the dictionary stack overflowed sözlük yığını taştı +@msg the editing data's hash has no place düzenleme verilerinin karması için yer yok +@msg the entities the document declares expand to too much text belgenin bildirdiği varlıklar çok fazla metne genişliyor +@msg the envelope's content has no bounds zarfın içeriğinin sınırları yok +@msg the envelope's mesh is malformed zarfın ağı hatalı biçimlendirilmiş +@msg the file holds no `variableSets` element dosyada `variableSets` öğesi yok +@msg the file is larger than {_1} MB dosya {_1} MB'tan büyük +@msg the file is too large dosya çok büyük +@msg the file was updated or encrypted already dosya zaten güncellenmiş veya şifrelenmiş +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) bir satırın ilk karakterinden önce aralığı ayarlanacak bir karakter yoktur: yalnızca yukarı ve aşağı hareket eder (dy) +@msg the font {_1} is not available {_1} yazı tipi kullanılamıyor +@msg the format has no file extension biçimin dosya uzantısı yok +@msg the graphic can't be read: {e} grafik okunamıyor: {e} +@msg the graphic is empty grafik boş +@msg the graphic's data is damaged grafiğin verileri hasarlı +@msg the graphic's document has no artboard grafiğin belgesinde çalışma yüzeyi yok +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them `{name}` için `{cell}` başlığı `{other}` değişkeni olarak geri okunurdu: birini yeniden adlandırın +@msg the image `{path}` isn't followed: {r} `{path}` görüntüsü izlenmiyor: {r} +@msg the image has no embedded pixels görüntüde gömülü piksel yok +@msg the image is empty görüntü boş +@msg the image is fully transparent görüntü tamamen saydam +@msg the image is not on an artboard; pass rect görüntü bir çalışma yüzeyinde değil; rect verin +@msg the image's pixels are missing görüntünün pikselleri eksik +@msg the image's pixels aren't in the document görüntünün pikselleri belgede değil +@msg the image's pixels can't be read: {e} görüntünün pikselleri okunamıyor: {e} +@msg the image's transparency doesn't fit it görüntünün saydamlığı görüntüye uymuyor +@msg the job would print more than {MAX_PAGES} pages iş {MAX_PAGES} sayfadan fazla yazdırırdı +@msg the job would print more than {_1} pages iş {_1} sayfadan fazla yazdırırdı +@msg the layers hold no art to mask katmanlarda maskelenecek resim yok +@msg the layout moved düzen taşındı +@msg the library has a group `{name}` already kütüphanede `{name}` grubu zaten var +@msg the library has no group `{name}` kütüphanede `{name}` grubu yok +@msg the library has no item `{item}` kütüphanede `{item}` öğesi yok +@msg the library has no item `{other}` kütüphanede `{other}` öğesi yok +@msg the library holds {MAX_GROUPS} groups already kütüphanede zaten {MAX_GROUPS} grup var +@msg the library holds {MAX_ITEMS} items of this kind already kütüphanede bu türden zaten {MAX_ITEMS} öğe var +@msg the name is empty ad boş +@msg the object has no fill, stroke or effect nesnenin dolgusu, konturu veya efekti yok +@msg the object has no graphic style (pass `name`) nesnenin grafik stili yok (`name` verin) +@msg the objects don't match the plan nesneler planla eşleşmiyor +@msg the open and permissions passwords must differ açma ve izin parolaları farklı olmalıdır +@msg the operand stack overflowed işlenen yığını taştı +@msg the page has no area sayfanın alanı yok +@msg the page's type in an unread object's box isn't in its page art okunmamış bir nesnenin kutusundaki sayfa metni, sayfa resminde yok +@msg the paint behind the proxy is not a gradient vekilin arkasındaki boya bir degrade değil +@msg the parent must be a layer or sublayer üst öğe bir katman veya alt katman olmalıdır +@msg the path can't be extended yol uzatılamıyor +@msg the path can't be inside one of the objects to attach yol, iliştirilecek nesnelerden birinin içinde olamaz +@msg the path has no segments to measure yolda ölçülecek parça yok +@msg the path is empty yol boş +@msg the path is too short to shorten that much yol o kadar kısaltılamayacak kadar kısa +@msg the picture has no objects resimde nesne yok +@msg the program runs too long program çok uzun süre çalışıyor +@msg the program uses too much memory program çok fazla bellek kullanıyor +@msg the rectangle must have a width and a height dikdörtgenin bir genişliği ve bir yüksekliği olmalıdır +@msg the rectangle needs a size and must lie on the canvas dikdörtgenin bir boyutu olmalı ve tuvalin üzerinde bulunmalıdır +@msg the selected art has no width seçili resmin genişliği yok +@msg the selection draws nothing seçim hiçbir şey çizmiyor +@msg the selection has no area seçimin alanı yok +@msg the selection has no art to export seçimde dışa aktarılacak resim yok +@msg the selection has no bounds seçimin sınırları yok +@msg the selection has no graphic style seçimin grafik stili yok +@msg the slice would leave the canvas dilim tuvalin dışına çıkardı +@msg the spot colour couldn't be added spot renk eklenemedi +@msg the subpath has no ends to extend (it is closed or a single point) alt yolun uzatılacak ucu yok (kapalı veya tek bir nokta) +@msg the symbols are nested too deeply to expand their instances semboller, örnekleri genişletilemeyecek kadar derin iç içe geçmiş +@msg the text file is too large to place as type (at most {_1} MB) metin dosyası metin olarak yerleştirilemeyecek kadar büyük (en fazla {_1} MB) +@msg the text is empty metin boş +@msg the text is over {_1} MB metin {_1} MB'tan büyük +@msg the top object must be a path üstteki nesne bir yol olmalıdır +@msg the trace was cancelled izleme iptal edildi +@msg the trailer has no size trailer'ın boyutu yok +@msg the type object is flattened to nothing metin nesnesi düzleştirilince hiçbir şey kalmıyor +@msg the value of axis `{tag}` must be a number or null `{tag}` ekseninin değeri bir sayı veya null olmalıdır +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets değişken kütüphanesi {MAX_LIBRARY_DATASETS} taneden fazla veri kümesi bildiriyor +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities değişken kütüphanesi {MAX_LIBRARY_ENTITIES} taneden fazla varlık bildiriyor +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables değişken kütüphanesi {MAX_LIBRARY_VARIABLES} taneden fazla değişken bildiriyor +@msg the variable library declares no variables değişken kütüphanesi hiçbir değişken bildirmiyor +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes değişken kütüphanesi {MAX_LIBRARY_BYTES} bayttan büyük +@msg the variable library is not UTF-8 değişken kütüphanesi UTF-8 değil +@msg the variable library is not well-formed XML: {e} değişken kütüphanesi iyi biçimlendirilmiş bir XML değil: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep değişken kütüphanesi öğeleri {MAX_LIBRARY_NESTING} düzeyden daha derin iç içe yerleştiriyor +@msg the variable library's document type declaration holds something other than entities of plain text değişken kütüphanesinin belge türü bildirimi, düz metin varlıklarından başka bir şey içeriyor +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes değişken kütüphanesinin varlıkları {MAX_LIBRARY_BYTES} bayttan fazlasına genişliyor +@msg the window has no dock pencerede yerleştirme alanı yok +@msg the written PDF has no catalog yazılan PDF'de katalog yok +@msg the written PDF has no cross-reference table yazılan PDF'de çapraz başvuru tablosu yok +@msg the written PDF's metadata can't be edited yazılan PDF'nin meta verileri düzenlenemiyor +@msg there are no colour swatches to draw çizilecek renk örneği yok +@msg there is already a design named `{name}` `{name}` adlı bir tasarım zaten var +@msg there is already a design named `{to}` `{to}` adlı bir tasarım zaten var +@msg there is already a toolbar named `{name}` `{name}` adlı bir araç çubuğu zaten var +@msg there is already a toolbar named `{to}` `{to}` adlı bir araç çubuğu zaten var +@msg there is no library: create one first kütüphane yok: önce bir tane oluşturun +@msg these paths are too degenerate for Shaper bu yollar Şekillendirici için fazla yozlaşmış +@msg thumbnail: no page {v} (the {what} has {count}) thumbnail: {v} numaralı sayfa yok ({what} içinde {count} tane var) +@msg tileRange of {what}: {e} {what} için tileRange: {e} +@msg title can't be empty title boş olamaz +@msg to (the new name) is required to (yeni ad) gereklidir +@msg to must be fill or stroke to fill veya stroke olmalıdır +@msg to must be smooth or corner to smooth veya corner olmalıdır +@msg too large for a zip archive: package to a folder bir zip arşivi için çok büyük: bir klasöre paketleyin +@msg too many Shaper edits çok fazla Şekillendirici düzenlemesi +@msg too many `points` (at most 100000) çok fazla `points` (en fazla 100000) +@msg too many characters: at most {MAX_CHARS} çok fazla karakter: en fazla {MAX_CHARS} +@msg too many dots for this size: lower the frequency bu boyut için çok fazla nokta: frekansı düşürün +@msg too many filters over one file tek bir dosya üzerinde çok fazla filtre +@msg too many overlapping paths for Shaper Şekillendirici için çok fazla çakışan yol +@msg too many saved clips çok fazla kayıtlı kırpma +@msg too many saved graphics states çok fazla kayıtlı grafik durumu +@msg toolbar (a toolbar's name) must be a string toolbar (bir araç çubuğunun adı) bir dize olmalıdır +@msg toolbar name can't be empty araç çubuğu adı boş olamaz +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (varsayılan) veya "omit" +@msg two saved selections would be named `{_1}` iki kayıtlı seçimin adı `{_1}` olurdu +@msg type dimension cannot be represented with this text transform metin boyutu bu metin dönüşümüyle ifade edilemiyor +@msg type dimension must be finite metin boyutu sonlu olmalıdır +@msg unexpected resources beklenmeyen kaynaklar +@msg unknown CMYK profile `{name}` bilinmeyen CMYK profili `{name}` +@msg unknown File Info field `{k}` bilinmeyen Dosya Bilgisi alanı `{k}` +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges içinde bilinmeyen OpenType özelliği `{t}` (`features` parametresinin aldığı etiketler) +@msg unknown PDF preset `{name}` (presets: {_1}) bilinmeyen PDF ön ayarı `{name}` (ön ayarlar: {_1}) +@msg unknown Warp style bilinmeyen Çarpıtma stili +@msg unknown alignToPath `{a}` bilinmeyen alignToPath `{a}` +@msg unknown arrowhead {v} bilinmeyen ok ucu {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) bilinmeyen artboardLayout anahtarı `{k}` (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` bilinmeyen öznitelik `{attribute}` +@msg unknown attribute `{k}` bilinmeyen öznitelik `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) bilinmeyen eksen `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` bilinmeyen karıştırma modu `{b}` +@msg unknown blend mode {b} bilinmeyen karıştırma modu {b} +@msg unknown brush type `{o}` bilinmeyen fırça türü `{o}` +@msg unknown category `{c}` bilinmeyen kategori `{c}` +@msg unknown category `{c}` ({_1}) bilinmeyen kategori `{c}` ({_1}) +@msg unknown character `{c}` bilinmeyen karakter `{c}` +@msg unknown character style number field bilinmeyen karakter stili sayı alanı +@msg unknown effect `{e}` bilinmeyen efekt `{e}` +@msg unknown end {e} bilinmeyen uç {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) bilinmeyen flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution veya kayıtlı bir ön ayar: bkz. flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) bilinmeyen biçim `{f}` (vcswatches, gpl, ase veya css) +@msg unknown from {f} bilinmeyen from {f} +@msg unknown graph type `{t}` bilinmeyen grafik türü `{t}` +@msg unknown grid colours `{name}` (one of {_1}) bilinmeyen ızgara renkleri `{name}` (şunlardan biri: {_1}) +@msg unknown gridType `{t}` bilinmeyen gridType `{t}` +@msg unknown highlight `{v}` bilinmeyen vurgu `{v}` +@msg unknown intent `{i}` bilinmeyen amaç `{i}` +@msg unknown issue kind {id} bilinmeyen sorun türü {id} +@msg unknown issue kind {v} bilinmeyen sorun türü {v} +@msg unknown knockout state {k} bilinmeyen nakavt durumu {k} +@msg unknown language `{language}` (one of {_1}) bilinmeyen dil `{language}` (şunlardan biri: {_1}) +@msg unknown language `{l}` (one of {_1}) bilinmeyen dil `{l}` (şunlardan biri: {_1}) +@msg unknown method `{m}` ({_1}) bilinmeyen yöntem `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) bilinmeyen mod `{other}` (gray, rgb, hsb, lab, cmyk veya web) +@msg unknown mode `{other}` (mono|cmyk) bilinmeyen mod `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) bilinmeyen mod `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) bilinmeyen mod `{other}` (set, add, subtract) +@msg unknown mode `{o}` bilinmeyen mod `{o}` +@msg unknown mode {m} bilinmeyen mod {m} +@msg unknown model `{other}` bilinmeyen model `{other}` +@msg unknown model `{other}` (keep, new or document) bilinmeyen model `{other}` (keep, new veya document) +@msg unknown op {o} bilinmeyen op {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back bilinmeyen order `{o}`: toggle, front, forward, backward veya back +@msg unknown overprints `{v}` (preserve, simulate or discard) bilinmeyen overprints `{v}` (preserve, simulate veya discard) +@msg unknown panel bilinmeyen panel +@msg unknown pieLegend `{v}` (none, standard or wedges) bilinmeyen pieLegend `{v}` (none, standard veya wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) bilinmeyen piePosition `{v}` (even, ratio veya stacked) +@msg unknown pieSort `{v}` (none, all or first) bilinmeyen pieSort `{v}` (none, all veya first) +@msg unknown plate `{n}` bilinmeyen kalıp `{n}` +@msg unknown preference `{k}` bilinmeyen tercih `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) bilinmeyen profil {id} (bkz. stroke.widthProfile.list) +@msg unknown proof target `{t}` bilinmeyen prova hedefi `{t}` +@msg unknown quotes key `{k}` (double, single) bilinmeyen quotes anahtarı `{k}` (double, single) +@msg unknown resolution `{v}` bilinmeyen çözünürlük `{v}` +@msg unknown rule `{rule}` ({_1}) bilinmeyen kural `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) bilinmeyen kural `{r}` (bkz. color.harmony) +@msg unknown setting `{k}` bilinmeyen ayar `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) bilinmeyen şekil `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) bilinmeyen spacing `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) bilinmeyen tileType `{t}` ({_1}) +@msg unknown units bilinmeyen birimler +@msg unknown units `{u}` bilinmeyen birimler `{u}` +@msg unknown value axis `{v}` (left, right or both) bilinmeyen değer ekseni `{v}` (left, right veya both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) bilinmeyen çeşitleme `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` bilinmeyen çarpıtma stili `{s}` +@msg unknown {key} `{v}` (none, short or full) bilinmeyen {key} `{v}` (none, short veya full) +@msg unknown {key} profile `{n}` bilinmeyen {key} profili `{n}` +@msg unknown {k} key `{key}` (size, position) bilinmeyen {k} anahtarı `{key}` (size, position) +@msg unreadable object okunamayan nesne +@msg unsafe destination file name güvenli olmayan hedef dosya adı +@msg use paths or compound paths yolları veya bileşik yolları kullanın +@msg useArtboards must be true or false, not {v} useArtboards true veya false olmalıdır, {v} değil +@msg value for `{var}` must match its variable kind `{var}` değeri değişkeninin türüyle eşleşmelidir +@msg variable `{_1}` is already defined `{_1}` değişkeni zaten tanımlı +@msg variable `{name}` has no `trait` `{name}` değişkeninde `trait` yok +@msg variable `{name}` is already defined `{name}` değişkeni zaten tanımlı +@msg variable `{name}` is declared twice `{name}` değişkeni iki kez bildirilmiş +@msg variable `{to}` already exists `{to}` değişkeni zaten var +@msg view is required view gereklidir +@msg view must be one of {_1} view şunlardan biri olmalıdır: {_1} +@msg warpStyle can only edit an applied Warp effect warpStyle yalnızca uygulanmış bir Çarpıtma efektini düzenleyebilir +@msg width and height must be more than 0 and at most {limit} width ve height 0'dan büyük ve en fazla {limit} olmalıdır +@msg width factors must be finite genişlik çarpanları sonlu olmalıdır +@msg width must be positive width pozitif olmalıdır +@msg width out of range genişlik aralık dışında +@msg width/height must be > 0 width/height > 0 olmalıdır +@msg x and y must be finite numbers x ve y sonlu sayılar olmalıdır +@msg x and y must be given together x ve y birlikte verilmelidir +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone için bir anchor gerekir: anchor ile bir panelin yanına veya side (Start ya da End) ile yeni bir yerleştirme sütununa yerleştirin +@msg zoom must be positive zoom pozitif olmalıdır +@msg {C}: no effect at index {list} {C}: {list} dizininde efekt yok +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} korumalıdır ve kalır +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} korumalıdır: ayarları başka bir adla kaydedin +@msg {LABEL} produced an empty result {LABEL} boş bir sonuç üretti +@msg {_1} MB of document, at most {_2} MB {_1} MB belge, en fazla {_2} MB +@msg {_1} MB of images, at most {_2} MB {_1} MB görüntü, en fazla {_2} MB +@msg {_1} bytes of RGBA data for a {width} × {height} image {width} × {height} boyutlu bir görüntü için {_1} bayt RGBA verisi +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} okunamıyor veya okunduktan sonra değişmiş: önce yeniden bağlayın veya güncelleyin +@msg {_1} can't go into {into} {_1}, {into} içine konulamaz +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} yazdırmayı düşük çözünürlükle sınırlayamaz: PDF 1.4 veya sonrasını seçin ya da yüksek çözünürlüklü yazdırmaya izin verin +@msg {_1} files can't be opened yet {_1} dosyaları henüz açılamıyor +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1} dosyaları parolayla korunamaz: parolaları kaldırın veya standart seçmeyin +@msg {_1} files can't be {_2} {_1} dosyaları için şu yapılamaz: {_2} +@msg {_1} files can't carry editing data: turn off preserveEditing {_1} dosyaları düzenleme verileri taşıyamaz: preserveEditing seçeneğini kapatın +@msg {_1} files can't have PDF layers: turn off createLayers {_1} dosyalarında PDF katmanları olamaz: createLayers seçeneğini kapatın +@msg {_1} files need a creation date, and this platform has no clock to give one {_1} dosyaları için bir oluşturma tarihi gerekir ve bu platformda bunu verecek bir saat yok +@msg {_1} isn't a path {_1} bir yol değil +@msg {_1} options: {e} {_1} seçenekleri: {e} +@msg {_1} paints nothing to rasterize {_1} rasterleştirilecek hiçbir şey boyamıyor +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1} parolaları en fazla {MAX_LEGACY_PASSWORD} ASCII karakter içerebilir (PDF 1.7 ve sonrası her karakteri alır) +@msg {_1} with linked images or an image map writes several files: use document.export bağlantılı görüntüler veya bir görüntü eşlemi içeren {_1} birkaç dosya yazar: document.export kullanın +@msg {_1} writes one file per artboard here: use document.export, or name one artboard {_1} burada çalışma yüzeyi başına bir dosya yazar: document.export kullanın veya tek bir çalışma yüzeyi belirtin +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} piksel {_3} için çok büyük (kenar başına en fazla {max} piksel): ölçeği düşürün +@msg {_1}: belongs to another account {_1}: başka bir hesaba ait +@msg {_1}: not a folder of its own {_1}: kendine ait bir klasör değil +@msg {_1}: not a plain file {_1}: düz bir dosya değil +@msg {dest}: {e} {dest}: {e} +@msg {doc} has no folder {doc} belgesinin klasörü yok +@msg {file} isn't linked {file} bağlantılı değil +@msg {folder} is not a package folder {folder} bir paket klasörü değil +@msg {folder}: more than {max} files and folders {folder}: {max} taneden fazla dosya ve klasör +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} bir Canlı Boyama grubu değil +@msg {id} is not a slice {id} bir dilim değil +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} 0–{MAX_ADD_AROUND} pt olmalıdır +@msg {key} must be [x,y] {key} [x,y] olmalıdır +@msg {key} must be a percentage {min}..100 {key} {min}..100 aralığında bir yüzde olmalıdır +@msg {key} must be a positive number, not {n} {key} pozitif bir sayı olmalıdır, {n} değil +@msg {key} must be a positive number, not {v} {key} pozitif bir sayı olmalıdır, {v} değil +@msg {key} must be one of {_1} {key} şunlardan biri olmalıdır: {_1} +@msg {key} must be {_1}, got {s} {key} {_1} olmalıdır, verilen: {s} +@msg {key} must name a floating panel {key} yüzen bir paneli belirtmelidir +@msg {key} must name a panel in another group {key} başka bir gruptaki bir paneli belirtmelidir +@msg {key} must name a panel that shows {key} gösterilen bir paneli belirtmelidir +@msg {key} must name a registered panel {key} kayıtlı bir paneli belirtmelidir +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} dosyalarında saydamlık yoktur ve dosyada {what} olurdu: önce düzleştirin (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind} görüntüleri açılamıyor (bkz. document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}` seçeneklerden biri değil +@msg {k} has control characters{_1} {k} denetim karakterleri içeriyor{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k} çok uzun (en fazla {MAX_TEXT} karakter) +@msg {k} must be > 0 {k} > 0 olmalıdır +@msg {k} must be a number (pt) or a length such as "210 mm" {k} bir sayı (pt) veya "210 mm" gibi bir uzunluk olmalıdır +@msg {k} must be a number from {min} to {max} {k} {min} ile {max} arasında bir sayı olmalıdır +@msg {k} must be a number or "auto" {k} bir sayı veya "auto" olmalıdır +@msg {k} must be a string {k} bir dize olmalıdır +@msg {k} must be text {k} metin olmalıdır +@msg {k} must be true or false {k} true veya false olmalıdır +@msg {k} must be {{size?, position?}} {k} {{size?, position?}} olmalıdır +@msg {k}: can't read `{s}` as a length {k}: `{s}` bir uzunluk olarak okunamıyor +@msg {label} must be a number at most {max} {label} en fazla {max} olan bir sayı olmalıdır +@msg {label} must be a number from -1296 to 1296 {label} -1296 ile 1296 arasında bir sayı olmalıdır +@msg {label} must be a number from 1 to 10000 {label} 1 ile 10000 arasında bir sayı olmalıdır +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} yerleşiktir ve silinemez +@msg {name}: CRC or size mismatch {name}: CRC veya boyut uyuşmazlığı +@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}: bu Affinity dosyasının yalnızca gömülü önizlemesi okunabildi; uyarısıyla birlikte görmek için Dosya › Aç komutunu kullanın veya yerleştirmeden önce Affinity'den SVG ya da PDF olarak dışa aktarın +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; gömülü önizlemesi de okunamıyor ({e}) +@msg {std} files are CMYK, and the file would have colours in {_1} {std} dosyaları CMYK'dir ve dosyada {_1} renkleri olurdu +@msg {std} files embed their fonts, and the file would have one that isn't {std} dosyaları yazı tiplerini gömer ve dosyada gömülmemiş bir yazı tipi olurdu +@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}, bu belgenin okunduğu dosyadır ve okunurken bazı şeyler dışarıda kaldı ({what}): üzerine yazmak bunları kalıcı olarak kaybettirir. Bunun yerine başka bir dosya yazın veya yine de değiştirmek için acknowledgeLoss: true verin +@msg {ty} brushes need selected art (or params.{k}) {ty} fırçaları için seçili resim (veya params.{k}) gerekir +@msg {v} is not an asset id {v} bir varlık kimliği değil +@msg {what} can't be intertwined {what} iç içe geçirilemez +@msg {what} doesn't fit the paper with its bleed and marks {what}, taşma payı ve işaretleriyle birlikte kâğıda sığmıyor +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} {MAX_TILES} taneden fazla döşemeye yazdırılırdı: ölçeğini küçültün Playback Options Oynatma Seçenekleri Accelerated Hızlandırılmış Step by Step Adım Adım @@ -7447,3 +9250,12 @@ breakText Glyphs Glifler Grommets Kuşgözleri Grommet Inset Kuşgözlerinin kenara uzaklığı Diameter Çap +@msg `{key}` must be from 0.01 to 1000 pt `{key}` 0.01 ile 1000 pt arasında olmalıdır +@msg turn on at least one side en az bir kenarı açın +@msg with no hem and no grommets there is nothing to mark kenar kıvırma ve kuşgözü olmadan işaretlenecek bir şey yok +@msg select the banner, or mark every artboard afişi seçin veya her çalışma yüzeyini işaretleyin +@msg there is no banner to mark işaretlenecek afiş yok +@msg the banner has no width or height afişin genişliği veya yüksekliği yok +@msg the hem is too deep for the banner kenar kıvırma afiş için çok derin +@msg the grommet inset is too large for the banner kuşgözlerinin kenara uzaklığı afiş için çok büyük +@msg the spacing makes more than {MAX_GROMMETS} grommets bu aralık {MAX_GROMMETS} taneden fazla kuşgözü oluşturur diff --git a/docs/localization-parity.md b/docs/localization-parity.md index cd8dc33f30..53e5fa0e1f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -64,7 +64,7 @@ plus review. Native-speaker review is human work and isn't in the hours. | 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 | +| Turkish | `tr` | 9,175 rows, 3,446 of them `@msg` and 48 `@msgplural` (counted 2026-11-13); 41 messages, 62 menu strings, 26 `tl!` literals, 13 undo labels 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f3707717e6..d1ffa7ec13 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 Turkish catalog (`crates/ui-egui/src/i18n/tr.tsv`) has an `@msg` row for all but 41 of the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,446 `@msg` rows in all. 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 33238dc7f44d5830ef0d3e09920d3117e881ea3e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:15:11 -0800 Subject: [PATCH 12/82] Tests: the sweeps' recorded steps keep a path only where the command takes it nested_locations_keep_command_meaning expected object.setLiveShape's `path` in a recorded step to stay as sent, but that command does not take a `path` param, so the step reads the key as a file and the sweep sends the temp location. The case now expects that, and text.createInPath, whose `path` is the id of a path in the document, covers a key that names part of the document. --- crates/testkit/src/strategies.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index ac1c380cc3..c2543d4374 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -626,7 +626,9 @@ mod tests { /// The strings the sweeps send keep the meaning the command gives their key, at every depth: an /// export's folder grouping and a web link stay as sent, also inside a `command.batch` step and - /// a recorded step, and a real file or folder key there goes to the temp location. + /// a recorded step, and a real file or folder key there goes to the temp location. A key that names + /// part of the document in a recorded step's command stays, and the same key in a command that + /// doesn't take it as a param names a file. #[test] fn nested_locations_keep_command_meaning() { let spec = |id: &str| command_specs().iter().find(|c| c.id == id).unwrap_or_else(|| panic!("no command {id}")); @@ -649,10 +651,14 @@ mod tests { {"command": "object.path.simplify", "params": {"wholePaths": "junk"}}, ]) ); - let recorded = json!([["object.setLiveShape", {"path": "junk"}], ["document.compare", {"diffPath": "junk"}]]); + let recorded = json!([ + ["text.createInPath", {"path": "junk"}], + ["object.setLiveShape", {"path": "junk"}], + ["document.compare", {"diffPath": "junk"}], + ]); assert_eq!( safe_value("steps", &recorded, safe), - json!([["object.setLiveShape", {"path": "junk"}], ["document.compare", {"diffPath": safe}]]) + json!([["text.createInPath", {"path": "junk"}], ["object.setLiveShape", {"path": safe}], ["document.compare", {"diffPath": safe}]]) ); assert_eq!(safe_value("url", &json!("https://example.com/a"), safe), json!("https://example.com/a")); assert_eq!(safe_value("destinationFolder", &json!("junk"), safe), json!("/tmp/sweep/out.bin-folder")); From 5375b6e446ef45e9186ca518ac73f08ae16550fa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:15:26 -0800 Subject: [PATCH 13/82] .ai save: images with a Warp count against the envelope limit Opening an .ai file compares at most 256 envelopes and embedded images with a Warp with their drawn art, from one budget. Saving the editing data counted only envelopes, so a file with more than 256 of them in all was saved with images past the budget written live and no warning, and it opened with those images as their drawn look. - The writer's budget now counts every image written with its Warp as well as every envelope, in the order they are written. An image past it is saved as its drawn look with a warning, and an envelope past it as its distorted art, as before. - An image whose pixels can't be read is written as its gray outline, which the reader does not check, so it takes no part of the budget and keeps the warning about its pixels. - The envelope warning names the shared budget. --- crates/eps/src/ai_editing/tests.rs | 113 ++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 15 ++- crates/eps/src/ai_editing/writer/envelopes.rs | 6 +- crates/eps/src/ai_editing/writer/images.rs | 18 ++- crates/eps/src/ai_editing/writer/styles.rs | 28 ++++- docs/target-app-parity.md | 3 +- 6 files changed, 166 insertions(+), 17 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 967c982ad4..b2871b7f76 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -9600,6 +9600,119 @@ fn envelopes_past_the_readers_checks_are_written_as_their_art() { assert!(!notes.iter().any(|n| n == crate::import::ENVELOPE_DRAWN), "{notes:?}"); } +/// A document of `envelopes` envelopes and `images` embedded images with one Warp, the images first +/// when `images_first`, saved and opened again: the live envelopes, the images with their Warp live +/// and all the objects it opens with, the save's warnings and the open's notes. +fn envelopes_and_warped_images(envelopes: usize, images: usize, images_first: bool) -> (usize, usize, usize, Vec, Vec) { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let add_images = |doc: &mut Document| { + for _ in 0..images { + let photo = warped_photo(doc, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]); + doc.insert(Some(layer), usize::MAX, photo).unwrap(); + } + }; + if images_first { + add_images(&mut doc); + } + for _ in 0..envelopes { + let points = [(30.0, 10.0), (170.0, 0.0), (30.0, 90.0), (170.0, 90.0)].map(kurbo::Point::from).to_vec(); + let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }; + let envelope = envelope_node(&mut doc, kind, 50.0, false, EnvelopeOptions::NEW); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + } + if !images_first { + add_images(&mut doc); + } + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let a = back.layers[0].children().unwrap(); + let live = a.iter().filter(|n| matches!(n.kind, NodeKind::Envelope { .. })).count(); + let warped = a.iter().filter(|n| matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.iter().any(|e| e.id == "warp.arc")).count(); + (live, warped, a.len(), warnings, notes) +} + +/// Images with a Warp take the reader's checks as envelopes do: past them, an image is written as +/// its drawn look, with a warning, and the file opens with every object written live still live. +#[test] +fn warped_images_past_the_readers_checks_are_written_as_their_drawn_look() { + // GUARDS: images with a Warp were written live past the checks the envelopes left, and the + // file opened with the last one as its drawn look and no warning on saving. + let limit = crate::import::ENVELOPE_CHECKS; + let (live, warped, all, warnings, notes) = envelopes_and_warped_images(limit - 1, 2, false); + assert_eq!((live, warped, all), (limit - 1, 1, limit + 1), "{warnings:?} {notes:?}"); + assert!(warnings.iter().any(|w| w == writer::WARPED_IMAGES), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + assert!(!notes.iter().any(|n| n.contains("drawn look") || n == crate::import::ENVELOPE_DRAWN), "{notes:?}"); +} + +/// An image with a Warp whose pixels can't be read is written as its gray outline, which the reader +/// doesn't check: it leaves its check to the objects after it. +#[test] +fn a_warped_image_written_as_its_outline_takes_no_check() { + // GUARDS: an image written as its gray outline took a check, and the image after it lost its Warp. + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let limit = crate::import::ENVELOPE_CHECKS; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for _ in 0..limit - 1 { + let points = [(30.0, 10.0), (170.0, 0.0), (30.0, 90.0), (170.0, 90.0)].map(kurbo::Point::from).to_vec(); + let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }; + let envelope = envelope_node(&mut doc, kind, 50.0, false, EnvelopeOptions::NEW); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + } + let arc = || fx("warp.arc", serde_json::json!({"bend": 40.0})); + let mut broken = image_node(&mut doc, ImageBlob::png(b"not a png".to_vec()), (8, 8), Affine::new([10.0, 0.0, 0.0, 10.0, 20.0, 10.0]), None); + broken.appearance = Appearance { effects: vec![arc()], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, broken).unwrap(); + let photo = warped_photo(&mut doc, vec![arc()]); + doc.insert(Some(layer), usize::MAX, photo).unwrap(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(warnings.iter().any(|w| w == writer::IMAGES), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::WARPED_IMAGES), "{warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let a = back.layers[0].children().unwrap(); + let warped = a.iter().filter(|n| matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.iter().any(|e| e.id == "warp.arc")).count(); + assert_eq!(warped, 1, "{notes:?}"); +} + +/// Past the reader's checks, an image with a Warp whose pixels can't be read is still written as its +/// gray outline, with the warning for its pixels and not the one for the checks. +#[test] +fn an_unreadable_warped_image_past_the_checks_is_warned_about_its_pixels() { + // GUARDS: an image whose pixels can't be read was warned about as past the checks. + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let limit = crate::import::ENVELOPE_CHECKS; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for _ in 0..limit { + let points = [(30.0, 10.0), (170.0, 0.0), (30.0, 90.0), (170.0, 90.0)].map(kurbo::Point::from).to_vec(); + let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }; + let envelope = envelope_node(&mut doc, kind, 50.0, false, EnvelopeOptions::NEW); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + } + let mut broken = image_node(&mut doc, ImageBlob::png(b"not a png".to_vec()), (8, 8), Affine::new([10.0, 0.0, 0.0, 10.0, 20.0, 10.0]), None); + broken.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, broken).unwrap(); + let (_, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(warnings.iter().any(|w| w == writer::IMAGES), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::WARPED_IMAGES), "{warnings:?}"); +} + +/// Envelopes after images with a Warp get the checks the images left: past them, an envelope is +/// written as its art, with a warning. +#[test] +fn envelopes_get_the_checks_warped_images_left() { + // GUARDS: envelopes were written live past the checks the images with a Warp before them left. + let limit = crate::import::ENVELOPE_CHECKS; + let (live, warped, all, warnings, notes) = envelopes_and_warped_images(limit - 1, 2, true); + assert_eq!((live, warped, all), (limit - 2, 2, limit + 1), "{warnings:?} {notes:?}"); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::WARPED_IMAGES), "{warnings:?}"); + assert!(!notes.iter().any(|n| n.contains("drawn look") || n == crate::import::ENVELOPE_DRAWN), "{notes:?}"); +} + /// Every Envelope Options switch and the ends of Fidelity come back as saved. #[test] fn envelope_options_and_fidelity_are_read_back() { diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 879eb9d9f5..f1f04575f0 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -30,7 +30,7 @@ pub(super) use brushes::BRUSHES; pub(super) use envelopes::ENVELOPES; pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[cfg(test)] -pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; +pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, WARPED_IMAGES, same_appearance}; pub(super) use images::PAYLOAD; #[cfg(test)] @@ -275,9 +275,9 @@ pub(super) struct Writer<'a> { /// The object just written is an envelope's distorted art, which the plugin's `XP` follows /// ([`Self::envelope`]). deform: bool, - /// How many more envelopes may be written live: as many as the reader checks in one file - /// ([`crate::import::ENVELOPE_CHECKS`]). - envelopes_left: usize, + /// How many more envelopes and images with a Warp may be written live: as many as the reader + /// checks in one file ([`crate::import::ENVELOPE_CHECKS`]), which it spends on both. + checks_left: usize, } impl<'a> Writer<'a> { @@ -338,7 +338,7 @@ impl<'a> Writer<'a> { brush_set: brushes::Brushes::default(), brushes_live: true, deform: false, - envelopes_left: crate::import::ENVELOPE_CHECKS, + checks_left: crate::import::ENVELOPE_CHECKS, objects: 0, left_out: 0, definition_objects: 0, @@ -633,6 +633,7 @@ impl<'a> Writer<'a> { && self.clip.is_none() && !self.in_symbol && let Some(orig) = self.styled.get(&n.id).cloned() + && self.check_left(&orig) && let Some(name) = self.object_style(n, &orig) { return self.styled_object(n, &orig, &name); @@ -667,7 +668,9 @@ impl<'a> Writer<'a> { } NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), - NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im), xy), + NodeKind::Image(im) => { + self.image(&self.doc.shown_image(n, im), xy); + } NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { if !self.open_group("u") { diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index dcd7274291..5e5e9068c0 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -26,7 +26,7 @@ use super::{MAX_NEST, Writer, Xy, hide}; use crate::import::{DEFORM_PLUGIN, EnvelopeGridPoint as GridPoint, envelope_outline, envelope_upright, warp_number}; use crate::ps; -pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes of type, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; +pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes of type, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256 envelopes and images with a Warp, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; /// The most rows or columns of an envelope's grid written (Make with Mesh's own limit, and the /// reader's). @@ -287,7 +287,7 @@ impl Writer<'_> { /// another shape, or some of it is left out for the object limit ([`Self::take_objects`]). pub(super) fn envelope(&mut self, n: &Node, xy: Xy) -> bool { let NodeKind::Envelope { content, kind, fidelity, editing, options, frame } = &n.kind else { return false }; - if self.hidden > 0 || self.clip.is_some() || self.nest >= MAX_NEST || !self.symbols_live || self.envelopes_left == 0 { + if self.hidden > 0 || self.clip.is_some() || self.nest >= MAX_NEST || !self.symbols_live || self.checks_left == 0 { return false; } let Some(record) = record(n, content, kind, *fidelity, *editing, options, **frame) else { return false }; @@ -339,7 +339,7 @@ impl Writer<'_> { return false; } self.deform = true; - self.envelopes_left -= 1; + self.checks_left -= 1; true } diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 36c21c14eb..e4e7f47ac5 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -92,25 +92,32 @@ impl Samples { } impl Writer<'_> { + /// Whether [`Self::image`] can read `im`'s pixels, rather than write its gray outline. + pub(super) fn image_readable(&self, im: &ImageObject) -> bool { + self.doc.image_blob(im).and_then(Samples::of).is_some() + } + /// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on /// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is - /// cut to a clipping path, the image is in a clipping group of that path. - pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { + /// cut to a clipping path, the image is in a clipping group of that path. True when the image + /// is written as an image. + pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) -> bool { let doc = self.doc; let Some((samples, deep)) = doc.image_blob(im).and_then(Samples::of) else { let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1))); self.warn(IMAGES); - return self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy); + self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy); + return false; }; // A region that has nothing left hides the image. if self.clip.as_ref().is_some_and(|c| c.subpaths.iter().all(|sp| sp.anchors.is_empty())) { - return; + return false; } // The image, and where it is cut, the clipping group and its clipping path. let clipping = self.clip.as_ref().map_or(0, |c| 1 + super::object_count(c.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count())); if !self.take_objects(1 + clipping) { - return; + return false; } if deep { self.warn(IMAGE_DEPTH); @@ -156,6 +163,7 @@ impl Writer<'_> { self.end_group("Q", Xy::PLAIN); } self.clip = cut; + true } /// An embedded image whose pixels `xf` places in the document: `%AI5_File:`, diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 163d70381c..0a2bd78b7e 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -23,6 +23,7 @@ pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps"; pub(in crate::ai_editing) const PANEL_EMPTY: &str = "a Graphic Styles panel with no style written opens from the editing data with a new document's styles"; +pub(in crate::ai_editing) const WARPED_IMAGES: &str = "images with a Warp after the first 256 envelopes and images with a Warp are written as their drawn look, without their Warp, in the editing data for other apps"; pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style"; /// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. @@ -720,6 +721,23 @@ impl Writer<'_> { items_keep && m.children().is_none_or(|c| c.iter().all(|c| self.member_keeps_outline(c))) } + /// Whether `orig` may be written with its art style as far as the reader's checks go: an image + /// with a Warp takes one of them, as an envelope does ([`Self::envelope`]); with none left, it is + /// written as its drawn look, with a warning. One whose pixels can't be read takes none: it is + /// written as its gray outline ([`Self::image`]). + pub(super) fn check_left(&mut self, orig: &Node) -> bool { + if !warped_image(orig) || self.checks_left > 0 { + return true; + } + if let NodeKind::Image(im) = &orig.kind + && !self.image_readable(&self.doc.shown_image(orig, im)) + { + return true; + } + self.warn(WARPED_IMAGES); + false + } + /// Object `n` (as baking left it; `orig` before baking) with the style `name`: its lock and /// hidden state; its drawn look (a group of its vector art as baking left it, without shadows, /// glows or feathers, the object's transparency @@ -729,7 +747,8 @@ impl Writer<'_> { /// `1 (name) XW`. /// The look's group and the object itself are counted first ([`Self::take_objects`]): without /// room for them, nothing is written, and the object counts neither as written with its graphic - /// style nor as written with a brush on its stroke. + /// style nor as written with a brush on its stroke. An image written as an image takes one of + /// the reader's checks ([`Self::check_left`]). pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { let own = match &orig.kind { NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), @@ -781,7 +800,12 @@ impl Writer<'_> { match &orig.kind { NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), - NodeKind::Image(im) => self.image(&self.doc.shown_image(orig, im), xy), + // The reader checks the object only when it is an image, not its gray outline. + NodeKind::Image(im) => { + if self.image(&self.doc.shown_image(orig, im), xy) { + self.checks_left = self.checks_left.saturating_sub(1); + } + } NodeKind::Group { children, .. } => { self.line("u"); for c in children { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..2082c20811 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -621,6 +621,7 @@ since 2026-10-01. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. - **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. - **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. +- **`.ai` saves count images with a Warp against the envelope limit:** opening an `.ai` file compares at most 256 envelopes and images with a Warp with their drawn art, and saving the `.ai` editing data now counts both against those 256, in the order they are written. An image with a Warp past them is saved as its drawn look, without its Warp, and an envelope past them as its distorted art, each with a warning on saving. An image with a Warp whose pixels can't be read is saved as its gray outline and is not counted. Before, saving counted only envelopes, so images with a Warp were saved live past the 256 without a warning, and the file opened with the ones past them as their drawn art. The limit of 256 is VectorCraft's own (eps `ai_editing::tests::warped_images_past_the_readers_checks_are_written_as_their_drawn_look`, `envelopes_get_the_checks_warped_images_left`, `a_warped_image_written_as_its_outline_takes_no_check`, `an_unreadable_warped_image_past_the_checks_is_warned_about_its_pixels`). Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. - **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. @@ -1638,7 +1639,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). - A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 envelopes and images with a Warp in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). 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). From f384398494561aedb6e12973d67cfed2d455a22a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:08:36 -0800 Subject: [PATCH 14/82] Image Trace: preset trace counts measured on current main The anchor, pixel-difference and staircase figures for the new preset values are measured again with JPEG block smoothing and the Limited palette change in main. 16 Colors makes 20% fewer anchors, and Low Fidelity Photo leaves 96 staircases of 2 px steps where it left 77. --- docs/image-trace-quality.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 2b2b363433..0c18767785 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -259,9 +259,10 @@ leaves 1.4 specks (was 0.5), and Outline's stroked centre lines lie 0.095 px off anchors a stroke (was 3.0). ΔE changes by 0.01 or less except Line Art and Outline (2.62 and 2.61 to 2.66 and 2.67); edge ΔE rises in 8 of the 12, for example 4.79 to 5.25 (16 Colors), 3.98 to 4.46 (3 Colors) and 9.46 to 10.03 (Black and White Logo). On synthetic shapes and photos of 300 to 4,000 px traced outside this harness, -High Fidelity Photo makes 41% fewer anchors and 16 Colors 15% fewer; the mean pixel difference of each -trace changes by −0.23 to +0.25 of a level; 16 Colors leaves 91 staircases of steps of 2 px or more where it left 85, and High -Fidelity Photo 473 where it left 461. The tables below are the previous measurement, not a rerun. +High Fidelity Photo makes 41% fewer anchors and 16 Colors 20% fewer; the mean pixel difference of each +trace changes by −0.23 to +0.26 of a level; 16 Colors leaves 41 staircases of steps of 2 px or more where it left 40, High +Fidelity Photo 440 where it left 391, and Low Fidelity Photo 96 where it left 77. The tables below are the previous +measurement, not a rerun. Test: `presets_have_their_paths_corners_and_noise` in `crates/trace/src/tests.rs`. ## A simpler contour walk From 39dc6c365beeb99c26466d87987f937ec66916a3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:11:10 -0800 Subject: [PATCH 15/82] Type: Convert to Text and Remove Breaks on a selected story include its locked and hidden frames After Select All in a frame of threaded text, Convert to Text skipped the story's locked and hidden frames, even when `ids` named them, and left a paragraph whose text started in one as a list. Remove Line Breaks and Remove Paragraph Breaks also kept the breaks in those frames. A selected story is one text, as it already is for Apply Bullets and Apply Numbers and the Character and Paragraph panels, so both commands now change every frame of the story, as one undo step. - text.convertListToText converts every list paragraph of the story, locked and hidden frames included; `ids` naming a locked or hidden frame converts its paragraphs too. A call that changes nothing adds no undo step. - addon.cleanup.removeBreaks removes the breaks in every frame of the selected story. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/addons/cleanup/text.rs | 10 +- crates/engine/src/cmd/panelcmds.rs | 23 +-- crates/engine/src/tests/textedit.rs | 144 +++++++++++++------ docs/mcp.md | 8 +- docs/target-app-parity.md | 2 +- 5 files changed, 119 insertions(+), 68 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 052ba901ed..f9fdba86ae 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Remove Breaks", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}", + "{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story, locked and hidden frames included), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}", has_selection, remove_breaks ), @@ -796,8 +796,8 @@ fn apply_cuts(t: &mut TextObject, cuts: &[(usize, bool)], brk: char) -> usize { } /// The breaks `brk` in the text of the frames of `story`, read as one text: a break at the end or -/// start of a frame between words of the frames on either side becomes a space. Each editable frame -/// comes with its own breaks, at its own bytes. +/// start of a frame between words of the frames on either side becomes a space. Each frame comes +/// with its own breaks, at its own bytes, its locked and hidden frames included. fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(usize, bool)>)> { let texts: Vec<(NodeId, String)> = story.iter().filter_map(|id| Some((*id, text_of(d, *id)?.plain_text()))).collect(); let joined: String = texts.iter().map(|(_, t)| t.as_str()).collect(); @@ -811,9 +811,7 @@ fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(us while let Some((at, space)) = cuts.next_if(|(at, _)| *at < end) { own.push((at - start, space)); } - if d.is_editable(id) { - out.push((id, own)); - } + out.push((id, own)); start = end; } out diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f9c4b10049..5e96bdd34b 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { "Convert to Text", [], None, - "{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches; after Select All in a frame of threaded text, without `ids`, `id` or a range, every paragraph of the story's frames that are neither locked nor hidden; while the story is selected, its locked and hidden frames are not converted even when named, nor a paragraph whose text starts in one) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them (a whole story that Select All selected stays selected when the call converts all of it); in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}", + "{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches; after Select All in a frame of threaded text, without `ids`, `id` or a range, every paragraph of every frame of the story, locked and hidden frames included) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them (a whole story that Select All selected stays selected when the call converts all of it); in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}", has_doc, convert_list_to_text ), @@ -1305,19 +1305,11 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { use vectorcraft_doc::text::ParaList; let range = super::typecmd::TextRange::parse(p, C)?; // After Select All in a frame of threaded text, a call that names no type and no range takes - // every paragraph of the story's editable frames; its locked and hidden frames stay as they are - // whichever way they are named. - let full = super::textedit::editing_story(s); - let story: Vec = full.iter().copied().filter(|f| s.doc().is_ok_and(|st| st.doc.is_editable(*f))).collect(); + // every paragraph of every frame of the story, its locked and hidden frames included: the story + // is one text. + let story = super::textedit::editing_story(s); let named = ["ids", "id"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())); - let ids = if !story.is_empty() && !named && range.is_none() { - story.clone() - } else { - super::typecmd::text_targets(s, p, C)?.into_iter().filter(|id| !full.contains(id) || story.contains(id)).collect() - }; - // The selected story's locked and hidden frames: no paragraph whose text starts in one is - // converted, and none of their paragraphs is restyled. - let held: Vec = full.iter().copied().filter(|f| !story.contains(f)).collect(); + let ids = if !story.is_empty() && !named && range.is_none() { story.clone() } else { super::typecmd::text_targets(s, p, C)? }; // Converting the whole story leaves it selected. let whole_story = !story.is_empty() && range.is_none() && story.iter().all(|f| ids.contains(f)); let editing = match super::textedit::editing_range(s)? { @@ -1351,7 +1343,6 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { let mut converted = 0; for story in &stories { let frames: Vec<&vectorcraft_doc::TextObject> = story.iter().filter_map(|id| text(*id)).collect(); - let is_held = |f: usize| story.get(f).is_some_and(|id| held.contains(id)); let mut paras: Vec = vec![]; let mut named_parts = vec![]; let plains: Vec = frames.iter().map(|t| t.plain_text()).collect(); @@ -1377,7 +1368,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { } for (i, named) in named_parts { if let Some(para) = paras.get_mut(i) { - let listed = para.main_part().is_some_and(|p| p.list.is_some() && !is_held(p.frame)); + let listed = para.main_part().is_some_and(|p| p.list.is_some()); para.converts |= named && listed; } } @@ -1467,7 +1458,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { let tab = vectorcraft_doc::TabStop { position: x, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' }; style.tabs.insert(i, tab); } - for part in story_para.parts.iter().filter(|q| (std::ptr::eq(*q, para) || q.bytes.is_empty()) && !is_held(q.frame)) { + for part in story_para.parts.iter().filter(|q| std::ptr::eq(*q, para) || q.bytes.is_empty()) { if let Some((stops, _)) = frame_edits.get_mut(part.frame) { stops.push((part.index, style.clone())); } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index adcc35d352..9dc35ff99b 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2265,6 +2265,36 @@ fn remove_breaks_takes_the_whole_story_that_select_all_selected() { assert!(obj(&s, a).plain_text().contains('\n') && obj(&s, b).plain_text().contains('\n')); } +#[test] +fn remove_breaks_of_the_whole_story_takes_locked_and_hidden_frames() { + for command in ["object.lock", "object.hide"] { + for held_first in [false, true] { + let (mut s, a, b, story) = threaded_list_edited_in_its_second_frame(); + let (held, other) = if held_first { (a, b) } else { (b, a) }; + let unrelated = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "One\nTwo", "size": 12})).unwrap()["id"].as_u64().unwrap(); + let unrelated = NodeId(unrelated); + s.execute(command, &json!({"ids": [held.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": other.0, "anchor": 0, "caret": 0})); + assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0])); + let case = format!("{command}, held first: {held_first}"); + let steps = undo_labels(&s).len(); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(undo_labels(&s).len(), steps + 1, "{case}: one undo step"); + assert!(r["ids"].as_array().unwrap().contains(&json!(held.0)), "{case}: the held frame is changed"); + let joined = format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()); + assert_eq!(joined, story.trim_end_matches('\n').replace('\n', " "), "{case}: every frame's returns go"); + assert_eq!(obj(&s, unrelated).plain_text(), "One\nTwo", "{case}: other type is as it was"); + // No return is left: another call fails and adds no undo step. + assert!(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).is_err()); + assert_eq!(undo_labels(&s).len(), steps + 1, "{case}: no undo step when nothing changes"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), story, "{case}: undo restores the text"); + } + } +} + #[test] fn remove_breaks_on_a_selected_threaded_story_puts_a_space_between_its_frames() { let (mut s, a, b, story) = threaded_list_edited_in_its_second_frame(); @@ -2306,41 +2336,72 @@ fn convert_to_text_takes_the_whole_story_that_select_all_selected() { } } +/// Whether no paragraph of type `id` is a list. +fn no_list(s: &Session, id: NodeId) -> bool { + let t = obj(s, id); + (0..t.paragraph_count()).all(|k| t.para_at(k).list.is_none()) +} + +/// The bullets held as characters in the frames `ids`. +fn bullet_chars(s: &Session, ids: &[NodeId]) -> usize { + ids.iter().map(|id| obj(s, *id).plain_text().matches("\u{2022}\t").count()).sum() +} + #[test] -fn convert_to_text_of_the_whole_story_leaves_a_locked_frame() { - // Over MCP without ids, and as the Paragraph panel sends it, with every frame of the story. - for named in [false, true] { - let (mut s, a, b, _) = threaded_list_edited_in_its_second_frame(); - s.execute("object.lock", &json!({"ids": [b.0]})).unwrap(); - s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); - s.set_tool_option("textSelection", &json!({"editing": a.0, "anchor": 0, "caret": 0})); - assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0])); - // The first frame ends with a paragraph return, so the paragraph that starts the locked - // frame takes in the first frame's empty last part and starts its text there. - let in_a = obj(&s, a).plain_text(); - assert!(in_a.ends_with('\n')); - let own = in_a.split('\n').filter(|piece| !piece.is_empty()).count(); - let p = if named { json!({"ids": [a.0, b.0]}) } else { json!({}) }; - let r = s.execute("text.convertListToText", &p).unwrap(); - assert_eq!(r["ids"], json!([a.0]), "only the editable frame is changed (ids named: {named})"); - assert_eq!(r["converted"], json!(own), "only the paragraphs whose text starts in the editable frame convert"); - assert!(r["inserted"].as_array().unwrap().iter().all(|i| i["id"] == json!(a.0))); - let (ta, tb) = (obj(&s, a), obj(&s, b)); - assert!(ta.plain_text().contains("\u{2022}\t"), "the editable frame is converted"); - let markers = ta.plain_text().matches("\u{2022}\t").count() + tb.plain_text().matches("\u{2022}\t").count(); - assert_eq!(markers, own, "the locked frame's first paragraph keeps its list"); - // The converted text is longer, so the story flows on into the locked frame; its own - // paragraphs stay a list. - let last = tb.paragraph_count() - 2; - assert!(tb.para_at(last).list.is_some(), "the locked frame's paragraphs stay a list (ids named: {named})"); - assert!(!tb.plain_text().ends_with("\u{2022}\tAlpha beta.\n")); - assert!(s.tool_options().get("story").is_some(), "the whole story stays selected"); +fn convert_to_text_of_the_whole_story_converts_locked_and_hidden_frames() { + // Over MCP without ids, as the Paragraph panel sends it with every frame of the story, and with + // ids naming only the held frame. + for command in ["object.lock", "object.hide"] { + for held_first in [false, true] { + for call in ["none", "story", "held"] { + let (mut s, a, b, story) = threaded_list_edited_in_its_second_frame(); + let (held, other) = if held_first { (a, b) } else { (b, a) }; + let unrelated = + s.execute("text.create", &json!({"x": 10, "y": 500, "text": "One\nTwo", "size": 12})).unwrap()["id"].as_u64().unwrap(); + let unrelated = NodeId(unrelated); + s.execute("text.setFormat", &json!({"ids": [unrelated.0], "list": "bullet"})).unwrap(); + let unrelated_before = obj(&s, unrelated); + s.execute(command, &json!({"ids": [held.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": other.0, "anchor": 0, "caret": 0})); + assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0])); + let case = format!("{command}, held first: {held_first}, ids: {call}"); + let p = match call { + "none" => json!({}), + "story" => json!({"ids": [a.0, b.0]}), + _ => json!({"ids": [held.0]}), + }; + let steps = undo_labels(&s).len(); + let r = s.execute("text.convertListToText", &p).unwrap(); + assert_eq!(undo_labels(&s).len(), steps + 1, "{case}: one undo step"); + assert!(r["ids"].as_array().unwrap().contains(&json!(held.0)), "{case}: the held frame is changed"); + assert!(no_list(&s, held), "{case}: the held frame leaves its list"); + assert_eq!(obj(&s, unrelated), unrelated_before, "{case}: other type is as it was"); + if call == "held" { + // The other frame's paragraphs whose text starts there keep their list. + assert!(!no_list(&s, other), "{case}: the frame not named keeps its list"); + continue; + } + assert!(no_list(&s, other), "{case}: the other frame leaves its list"); + assert_eq!(bullet_chars(&s, &[a, b]), 12, "{case}: each paragraph's bullet is held once as characters"); + assert!(s.tool_options().get("story").is_some(), "{case}: the whole story stays selected"); + // Nothing is a list any more: another call changes nothing and adds no undo step. + s.execute("text.convertListToText", &p).unwrap(); + assert_eq!(undo_labels(&s).len(), steps + 1, "{case}: no undo step when nothing changes"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), story, "{case}: undo restores the text"); + assert!(!no_list(&s, a) && !no_list(&s, b), "{case}: undo restores the lists"); + } + } } } #[test] -fn convert_to_text_of_the_whole_story_leaves_a_paragraph_that_starts_in_a_locked_frame() { - for named in [false, true] { +fn convert_to_text_of_the_whole_story_converts_a_paragraph_that_runs_across_a_locked_frame() { + // The paragraph that runs from the first frame into the second starts in the locked frame, or + // ends in it. + for (named, lock_first) in [(false, true), (true, true), (false, false), (true, false)] { let mut s = session(); let story = format!("Alpha beta.\nAlpha beta.\n{}end.\nAlpha beta.\nAlpha beta.\n", "Gamma delta ".repeat(40)); let a = NodeId( @@ -2354,22 +2415,23 @@ fn convert_to_text_of_the_whole_story_leaves_a_paragraph_that_starts_in_a_locked s.execute("text.setFormat", &json!({"ids": [a.0, b.0], "list": "bullet"})).unwrap(); let in_a = obj(&s, a).plain_text(); assert!(!in_a.ends_with('\n') && !obj(&s, b).plain_text().starts_with('\n'), "a paragraph runs on from the first frame into the second"); - s.execute("object.lock", &json!({"ids": [a.0]})).unwrap(); - s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let (locked, other) = if lock_first { (a, b) } else { (b, a) }; + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); s.select_tool("type", ViewInfo::default()).unwrap(); - s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0})); + s.set_tool_option("textSelection", &json!({"editing": other.0, "anchor": 0, "caret": 0})); assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0])); + let case = format!("ids named: {named}, first frame locked: {lock_first}"); let p = if named { json!({"ids": [a.0, b.0]}) } else { json!({}) }; let r = s.execute("text.convertListToText", &p).unwrap(); - assert_eq!(r["ids"], json!([b.0]), "only the editable frame is changed (ids named: {named})"); - // The two paragraphs that start in the editable frame, and the empty one that ends the story. - assert_eq!(r["converted"], json!(3), "only the paragraphs that start in the editable frame convert"); + assert_eq!(r["ids"], json!([a.0, b.0]), "{case}: both frames are changed"); + // The five paragraphs with text, and the empty one that ends the story. + assert_eq!(r["converted"], json!(6), "{case}: every paragraph of the story converts"); let (ta, tb) = (obj(&s, a), obj(&s, b)); - assert_eq!(ta.plain_text(), in_a, "the locked frame's text is as it was"); - assert!((0..ta.paragraph_count()).all(|k| ta.para_at(k).list.is_some()), "the locked frame stays a list"); - assert!(tb.para_at(0).list.is_some(), "the paragraph the locked frame starts stays a list"); - assert!(!tb.plain_text().starts_with('\u{2022}')); - assert_eq!(tb.plain_text().matches("\u{2022}\t").count(), 2); + assert!(no_list(&s, a) && no_list(&s, b), "{case}: the story leaves its list"); + assert_eq!(bullet_chars(&s, &[a, b]), 5, "{case}: each paragraph's bullet is held once as characters"); + assert!(ta.plain_text().contains("\u{2022}\tGamma"), "{case}: the paragraph across the frames takes its bullet where its text starts"); + assert!(!tb.plain_text().starts_with('\u{2022}'), "{case}"); } } diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..727a29794f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8973,9 +8973,8 @@ the type it changed, how many paragraphs were `converted`, and the characters `i Type tool edits, `text.convertListToText` ends its typing first and moves its anchor and caret past the characters inserted at or before them, whether the call comes from the menu, MCP or the control channel. After Select All in a frame of threaded text, a call without `ids`, `id`, `start` or `end` converts every paragraph of -the story's frames that are neither locked nor hidden. While the story is selected, its locked and hidden frames are -not converted, even when `ids` names them, and neither is a paragraph whose text starts in one. When the call converts -all of its other frames, the whole story stays selected. +every frame of the story, locked and hidden frames included. `ids` naming a locked or hidden frame converts its +paragraphs too. When the call converts every frame of the story, the whole story stays selected. In threaded type, when the caret's character moved to another frame, the Type tool edits that frame with the caret next to the same character, and that frame becomes the selection when it was not selected. @@ -10665,7 +10664,8 @@ glyph of its first character lands where it was drawn. they were. Character styles stay; the object takes the top-left piece's place and paint. Returns `{id, joined, lines}`. - `addon.cleanup.removeBreaks {kind, ids?}` removes the forced line breaks (`kind: "line"`) or paragraph returns (`"paragraph"`) in the characters selected with a Type tool, else in all the text of the selected type. After - Select All in a frame of threaded text, it removes them from the text of every frame of the story, as one undo step. + Select All in a frame of threaded text, it removes them from the text of every frame of the story, locked and hidden + frames included, as one undo step. A break between two words becomes a space; merged paragraphs take the first one's paragraph style. The frames of a threaded story, selected all together or through Select All, are read as one text, so a break at the end of one frame before a word in the next one also becomes a space. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..b8258aece0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -993,7 +993,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows and Linux, F2, F3 and F4 run Cut, Copy and Paste alongside Ctrl+X, Ctrl+C and Ctrl+V, and in the web build they do so everywhere except macOS. The Edit menu keeps the Ctrl shortcuts, and Keyboard Shortcuts lists both keys of each row. On macOS only Cmd+X, Cmd+C and Cmd+V are the defaults. F2, F3 and F4 do not run these commands in a focused text field or while the Type tool edits text. A saved shortcut set that assigns one of these keys to another command keeps that assignment. Public reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). - 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`). + - 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, locked and hidden frames included, 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`, `textedit::convert_to_text_of_the_whole_story_converts_locked_and_hidden_frames`, `textedit::remove_breaks_of_the_whole_story_takes_locked_and_hidden_frames`). - 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`). - 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`). From 2f8301671ecb1d788baf73fbd806b5fe1fe1c7f2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:13:44 -0800 Subject: [PATCH 16/82] Blend: stroke gradients and dashes follow a key path the blend turns When a blend starts a key's path at another anchor or runs it the other way, the steps already read that key's width profile and pen recordings along the turned path. Its stroke gradient and dashes still followed the key's own direction, so steps near that key showed them flipped or shifted. - A gradient along the path starts where the turned path starts and runs backwards on a reversed one; a gradient across the path swaps sides on a reversed one. Stops that reflect an even number of times are left as they are. - Dashes run backwards on a reversed path, and exact dashes start where the turned path starts, when every subpath needs the same offset. - Two gradients with different stop counts keep a step (two stops at one offset) when they are resampled to common stops. --- crates/doc/src/blend.rs | 309 ++++++++++++++++++++++++++++++++++++-- docs/mcp.md | 6 +- docs/target-app-parity.md | 2 +- 3 files changed, 297 insertions(+), 20 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index b84de1cac3..0ce7e04ac5 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -19,11 +19,11 @@ use std::f64::consts::PI; use std::sync::Arc; -use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::{Color, Gradient, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint}; use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; -use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeLayer, WidthProfile}; +use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeGradientMode, StrokeLayer, WidthProfile}; use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; use crate::node::{Node, NodeId, NodeKind}; use crate::text::TextObject; @@ -60,7 +60,11 @@ fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> /// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops /// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks. fn link_at(g: &Gradient, t: f32) -> (Option, f32) { - let stops = &g.stops; + link_in(&g.stops, t) +} + +/// [`link_at`] of a gradient's `stops`. +fn link_in(stops: &[GradientStop], t: f32) -> (Option, f32) { let link = |s: &GradientStop| (s.swatch.clone(), s.tint); let Some(first) = stops.first() else { return (None, 1.0) }; if t <= first.offset { @@ -84,15 +88,30 @@ fn link_at(g: &Gradient, t: f32) -> (Option, f32) { stops.last().map_or((None, 1.0), link) } -/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred). +/// How close two places a gradient changes are for [`common_stops`] to give them one stop (two +/// where either gradient steps there: two of its stops at the same offset). +const STEP: f32 = 1e-4; + +/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred). An +/// offset given twice is a step: the second stop paints what `g` steps to there (its last stop +/// within [`STEP`] of it), or what it paints there when it does not step. fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient { let mut out = g.clone(); out.stops = offsets .iter() - .map(|&o| { - let (color, opacity) = g.sample_keep_model(o); - let (swatch, tint) = link_at(g, o); - GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) } + .enumerate() + .map(|(i, &o)| { + let after = i.checked_sub(1).and_then(|j| offsets.get(j)) == Some(&o); + let mut near = g.stops.iter().filter(|s| (s.offset - o).abs() < STEP); + let stepped = if after && near.next().is_some() { near.next_back() } else { None }; + match stepped { + Some(s) => GradientStop { opacity: s.opacity, swatch: s.swatch.clone(), tint: s.tint, ..GradientStop::new(o, s.color) }, + None => { + let (color, opacity) = g.sample_keep_model(o); + let (swatch, tint) = link_at(g, o); + GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) } + } + } }) .collect(); out @@ -111,7 +130,7 @@ fn gradient_breaks(g: &Gradient, out: &mut Vec) { } /// Two gradients of one kind with the same number of stops: as they are when the counts match, -/// else both resampled at every place either changes. +/// else both resampled at every place either changes, with two stops where either steps. fn common_stops(a: &Gradient, b: &Gradient) -> Option<(Gradient, Gradient)> { if a.kind != b.kind { return None; @@ -127,8 +146,22 @@ fn common_stops(a: &Gradient, b: &Gradient) -> Option<(Gradient, Gradient)> { gradient_breaks(b, &mut offs); offs.retain(|o| o.is_finite()); offs.sort_by(f32::total_cmp); - offs.dedup_by(|x, y| (*x - *y).abs() < 1e-4); - Some((resample_stops(a, &offs), resample_stops(b, &offs))) + offs.dedup_by(|x, y| (*x - *y).abs() < STEP); + let mut steps: Vec = + [a, b].into_iter().flat_map(|g| g.stops.windows(2).filter(|w| w[1].offset == w[0].offset).map(|w| w[0].offset)).collect(); + steps.sort_by(f32::total_cmp); + let mut at = Vec::with_capacity(offs.len() + steps.len()); + let mut k = 0; + for o in offs { + at.push(o); + while steps.get(k).is_some_and(|&s| s <= o - STEP) { + k += 1; + } + if steps.get(k).is_some_and(|&s| (s - o).abs() < STEP) { + at.push(o); + } + } + Some((resample_stops(a, &at), resample_stops(b, &at))) } fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option { @@ -606,13 +639,112 @@ fn reoriented(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wr out } -/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap -/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its -/// arrowheads. -fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { +/// Gradient `g` laid along a closed subpath that now starts at fraction `s` of its length: the +/// part from `s` on runs first, then the part before it, stepping where the old start now falls. +/// The stops follow its colors as [`Gradient::span`] reads them, mixed linearly, and run once. +/// One of `g`'s own stops keeps its swatch link (each stop of a step its own), and a stop between +/// two of them the link [`link_at`] reads there. +fn gradient_from(g: &Gradient, s: f32) -> Gradient { + let g = g.unrolled(); + let mut own = g.stops.clone(); + own.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let mut stops = Vec::new(); + // The part from `s` to the end runs first, then the part up to `s`. + for (from, to, at) in [(s, 1.0, 0.0), (0.0, s, 1.0 - s)] { + // The first of `own` not yet matched: the parts' stops run in order, so the links are + // found in one pass. + let mut j = 0; + for st in g.span(from, to).stops { + let p = from + (to - from) * st.offset; + while own.get(j).is_some_and(|o| o.offset <= p - STEP) { + j += 1; + } + let hit = (j..own.len()) + .take_while(|&i| own.get(i).is_some_and(|o| o.offset < p + STEP)) + .find(|&i| own.get(i).is_some_and(|o| o.color == st.color && o.opacity == st.opacity)); + let (swatch, tint) = match hit.and_then(|i| own.get(i).map(|o| (i, o))) { + Some((i, o)) => { + j = i + 1; + (o.swatch.clone(), o.tint) + } + None => link_in(own.get(j.saturating_sub(1)..(j + 1).min(own.len())).unwrap_or_default(), p), + }; + stops.push(GradientStop { offset: at + (to - from) * st.offset, swatch, tint, ..st }); + } + } + Gradient { stops, method: GradientMethod::Classic, spread: GradientSpread::Pad, repeats: 1, ..g.into_owned() } +} + +/// Dash pattern `d` read along subpaths of lengths `lens` turned as `o` says. Reversed, the +/// pattern runs backwards: from the start of a dash, that is the dash and then the other entries +/// in reverse order. Exact dashes also start where the turned subpaths now start. The pattern +/// restarts on every subpath at the one offset, so the offset moves only when every subpath needs +/// the same one (as subpaths whose lengths differ by whole periods do); dashes fitted to corners +/// ignore the offset, which stays as it is. A dash pattern starts once per subpath, so on a closed +/// subpath started elsewhere the dashes match the key's as far as its own start, and past it run +/// on without restarting there. +fn reorient_dash(d: &mut Dash, o: Orient, lens: &[f64]) { + if !d.is_dashed() || !d.offset.is_finite() { + return; + } + let mut pat = d.pattern.clone(); + // An odd pattern repeats twice per period (dash, gap, dash / gap, dash, gap). + if pat.len() % 2 == 1 { + pat.extend_from_within(..); + } + let period: f64 = pat.iter().sum(); + // Where along the key's own subpath the turned one starts (a closed one wraps round). + let at = if o.closed { o.seam() } else { o.start }; + let first = pat.first().copied().unwrap_or(0.0); + let offset = |len: f64| if o.reversed { first - at * len - d.offset } else { d.offset + at * len }; + let offsets: Vec = lens.iter().map(|&len| offset(len)).collect(); + let agree = offsets.first().is_some_and(|&a| { + offsets.iter().all(|&b| { + let r = (b - a).rem_euclid(period); + r.is_finite() && r.min(period - r) <= 1e-6 + }) + }); + if o.reversed { + if let Some(rest) = pat.get_mut(1..) { + rest.reverse(); + } + d.pattern = pat; + } + if let Some(&off) = offsets.first().filter(|_| agree && !d.align_corners) { + d.offset = off; + } +} + +/// A key's stroke read along its subpaths turned as `o` says (`lens`: their lengths): its width +/// profile (whose sides swap when reversed), pen pressure, tilt, bearing and rotation, its dashes, +/// a gradient along the path or across it (whose sides swap when reversed), and on a reversed +/// open path its arrowheads. +fn reorient_stroke(s: &mut StrokeLayer, o: Orient, lens: &[f64]) { if o.is_identity() { return; } + if let Some(d) = &mut s.dash { + reorient_dash(d, o, lens); + } + let along = s.gradient_mode == StrokeGradientMode::Along; + if s.path_gradient().is_some() + && let Paint::Gradient(g) = &mut s.paint + { + let start = along && o.seam() > 0.0; + if start { + g.gradient = gradient_from(&g.gradient, o.seam() as f32); + } + // Stops that reflect an even number of times read the same either way. + let (spread, runs) = g.gradient.painted_spread(); + let reverse = o.reversed && !(spread == GradientSpread::Reflect && runs % 2 == 0); + if reverse { + g.gradient.reverse(); + } + if start || reverse { + // The turned gradient is no longer the swatch's. + g.swatch = None; + } + } let same = |v: f64| v; if let Some(p) = &mut s.profile { let mut pts: Vec<(f64, (f64, f64))> = p.points.iter().map(|&(t, l, r)| (t, (l, r))).collect(); @@ -659,9 +791,11 @@ fn reorient_strokes(n: &mut Node, o: Orient) { if o.is_identity() { return; } + let lens: Vec = + node_path(n).map(|(p, ..)| p.subpaths.iter().map(|sp| PathData::new(vec![sp.clone()]).length()).collect()).unwrap_or_default(); for it in n.appearance.items.iter_mut() { if let AppearanceItem::Stroke(s) = it { - reorient_stroke(s, o); + reorient_stroke(s, o, &lens); } } } @@ -2627,6 +2761,149 @@ mod tests { assert!((rot(0.5, 0.499) - rot(0.5, 0.501)).abs() < 1.0, "no jump where the key started: {:?}", stroke(&blend.at(0.5)).rotation); } + #[test] + fn stroke_gradients_and_dashes_turn_with_a_key_path_started_elsewhere() { + let keyed = |path: PathData, edit: &dyn Fn(&mut StrokeLayer)| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + edit(s); + } + Node::path(NodeId(1), path, ap) + }; + let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + // How light the stroke's gradient is at `u` (0 black, 3 white). + let light = |s: &StrokeLayer, u: f32| match &s.paint { + Paint::Gradient(g) => g.gradient.sample(u).0.to_rgb().iter().sum::(), + p => panic!("a gradient: {p:?}"), + }; + // Whether dash pattern `d` draws a dash `at` this far along its subpath. + let dash_at = |d: &Dash, at: f64| { + let p = if d.pattern.len() % 2 == 1 { d.pattern.repeat(2) } else { d.pattern.clone() }; + let mut x = (at + d.offset).rem_euclid(p.iter().sum()); + for (i, v) in p.iter().enumerate() { + if x < *v { + return i % 2 == 0; + } + x -= v; + } + false + }; + let graded = |mode: StrokeGradientMode| { + move |s: &mut StrokeLayer| { + s.paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + s.gradient_mode = mode; + s.dash = Some(Dash { pattern: vec![1.0, 2.0, 3.0, 1.5], offset: 0.75, align_corners: false }); + } + }; + // An open key clicked at its last anchor runs the other way: a gradient along it or across + // it (whose sides swap) runs the other way too, and its dashes are read from its end. + let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 13.0, 0.0)], false)); + let plain = keyed(line(0.0), &|_| {}); + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let key = keyed(line(100.0), &graded(mode)); + let s = stroke(&Lerp::new(&plain, &key, (None, Some(1))).at(1.0)); + assert!(light(&s, 0.0) < 0.01 && light(&s, 1.0) > 2.99, "{mode:?}: {:?}", s.paint); + let (own, d) = (stroke(&key).dash.unwrap_or_default(), s.dash.unwrap_or_default()); + for k in 1..130 { + let u = k as f64 / 10.0 + 0.03; + assert_eq!(dash_at(&d, u), dash_at(&own, 13.0 - u), "at {u}: {d:?}"); + } + } + // A closed key started half way round starts its gradient along it there too, stepping + // where its own start was, and its dashes start there. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)); + let key = keyed(square(100.0), &graded(StrokeGradientMode::Along)); + let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &key, (Some(0), Some(2))).at(1.0)); + for (u, want) in [(0.0, 1.5), (0.25, 0.75), (0.499, 0.0), (0.501, 3.0), (0.75, 2.25), (1.0, 1.5)] { + assert!((light(&s, u) - want).abs() < 0.02, "at {u}: {} for {want}: {:?}", light(&s, u), s.paint); + } + let (own, d) = (stroke(&key).dash.unwrap_or_default(), s.dash.unwrap_or_default()); + for k in 1..200 { + let u = k as f64 / 10.0 + 0.03; + assert_eq!(dash_at(&d, u), dash_at(&own, u + 20.0), "at {u}: {d:?}"); + } + // Stops that reflect twice read the same either way; a link to a swatch stays. + let reflected = keyed(line(100.0), &|s| { + let mut g = Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::default() }; + g.stops[1].swatch = Some("Ink".into()); + s.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + s.gradient_mode = StrokeGradientMode::Along; + }); + assert_eq!(stroke(&Lerp::new(&plain, &reflected, (None, Some(1))).b).paint, stroke(&reflected).paint); + let linked = keyed(square(100.0), &|s| { + let mut g = Gradient::default(); + g.stops[1].swatch = Some("Ink".into()); + s.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + s.gradient_mode = StrokeGradientMode::Along; + }); + let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &linked, (Some(0), Some(2))).b); + let links = match &s.paint { + Paint::Gradient(g) => g.gradient.stops.iter().filter(|st| st.swatch.as_deref() == Some("Ink")).map(|st| st.offset).collect::>(), + p => panic!("a gradient: {p:?}"), + }; + assert!(links.contains(&0.5), "the stop the old start ended on keeps its link: {:?}", s.paint); + // Each stop of a step keeps its own link. + let step = keyed(square(100.0), &|s| { + let mut g = Gradient::default(); + let (mut before, mut after) = (GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.25, Color::WHITE)); + (before.swatch, after.swatch) = (Some("Ink".into()), Some("Paper".into())); + g.stops.splice(1..1, [before, after]); + s.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + s.gradient_mode = StrokeGradientMode::Along; + }); + let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &step, (Some(0), Some(2))).b); + let links = match &s.paint { + Paint::Gradient(g) => g.gradient.stops.iter().filter(|st| st.offset == 0.75).map(|st| st.swatch.clone()).collect::>(), + p => panic!("a gradient: {p:?}"), + }; + assert_eq!(links, vec![Some("Ink".into()), Some("Paper".into())], "{:?}", s.paint); + // Blended with a gradient of other stops, the steps keep that step. + let three = keyed(square(0.0), &|s| { + let mut g = Gradient::default(); + g.stops.insert(1, GradientStop::new(0.5, Color::BLACK)); + s.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + s.gradient_mode = StrokeGradientMode::Along; + }); + let s = stroke(&Lerp::new(&three, &key, (Some(0), Some(2))).at(0.75)); + assert!(light(&s, 0.501) - light(&s, 0.499) > 1.5, "{:?}", s.paint); + // Subpaths whose lengths call for different dash offsets keep the offset. + let rings = |x: f64, turn: usize| { + let mut inner = shapes::rectangle(Rect::new(x, 20.0, x + 7.0, 27.0)).subpaths.remove(0); + inner.anchors.rotate_left(turn); + PathData::new(vec![square(x).subpaths.remove(0), inner]) + }; + let key = keyed(rings(100.0, 2), &graded(StrokeGradientMode::Along)); + let blend = Lerp::new(&keyed(rings(0.0, 0), &|_| {}), &key, (Some(0), Some(2))); + let turned = match &blend.shape { + Shape::Path { pair, .. } => pair.orient.1, + _ => panic!("a path blend"), + }; + assert!((turned.seam() - 0.5).abs() < 1e-9, "both rings turn half way round: {turned:?}"); + let s = stroke(&blend.at(1.0)); + assert_eq!(s.dash.map(|d| d.offset), Some(0.75)); + } + + #[test] + fn gradients_of_other_stop_counts_keep_their_steps() { + let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }; + let stepped = Gradient::new( + vectorcraft_color::GradientKind::Linear, + vec![ + GradientStop::new(0.0, Color::WHITE), + GradientStop::new(0.5, Color::BLACK), + GradientStop::new(0.5, Color::WHITE), + GradientStop::new(0.50015, red), + GradientStop::new(1.0, Color::BLACK), + ], + ); + let (a, b) = common_stops(&stepped, &Gradient::default()).unwrap_or_else(|| panic!("one kind")); + let at = |g: &Gradient| g.stops.iter().map(|s| (s.offset, s.color)).collect::>(); + assert_eq!(at(&a), vec![(0.0, Color::WHITE), (0.5, Color::BLACK), (0.5, Color::WHITE), (0.50015, red), (1.0, Color::BLACK)]); + assert_eq!(b.stops.len(), 5); + assert_eq!(b.stops.get(1).map(|s| s.color), b.stops.get(2).map(|s| s.color), "a gradient without the step paints one color there"); + } + #[test] fn odd_dash_blends_to_solid_with_closed_gaps() { let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..1ed23b4fc5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10302,9 +10302,9 @@ How blends interpolate: closed shapes start where they twist least unless `start interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale, width profiles and the pen pressure, tilt, bearing and rotation recorded with the Paintbrush (a stroke without a recording counts as half pressure, half tilt and 0°, as brushes read it; angles turn the shorter way round; a key's -profiles, recordings and arrowheads turn with its path when the blend starts it at another anchor), while -caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with -different stop counts resample to a common set of stops. Groups pair their members in stacking order (members only +profiles, recordings, arrowheads, dashes and gradient along or across the stroke turn with its path when the blend +starts it at another anchor), while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one +kind with different stop counts resample to a common set of stops, keeping each step (two stops at one offset). Groups pair their members in stacking order (members only one group has grow out of the other's centre), compound paths stay compound paths, and type, symbol instances and images interpolate their transforms (type also its colours, stroke weight and size run by run; the words and the symbol switch halfway). New blends are knockout groups (`object.setProps {knockout}` changes it), drawn on the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..f7c55c8c52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1112,7 +1112,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512). View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). + - **Blend steps of 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, arrowheads, dashes and a gradient along or across the stroke 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`, `stroke_gradients_and_dashes_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 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, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. From 090293db4ebd3b66f3a28d2c1fef9fe05dcec00e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:22:12 -0800 Subject: [PATCH 17/82] Image Trace: preset docs name the enhanced versions and older figures The preset paragraphs say that the enhanced versions use the same Paths, Corners and Noise, and that Corners 50 applies to the other eight presets. The quality notes add the staircase and anchor rises in Line Art, Outline, 3 Colors, Sketched Art and Silhouettes, and say which figures were measured at the earlier values. The anchor-limit comments name the same settings in both places. --- crates/engine/src/cmd/buildcmds/trace.rs | 12 ++++++------ docs/image-trace-adjustments.md | 6 +++--- docs/image-trace-quality.md | 12 ++++++++---- docs/target-app-parity.md | 2 +- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 024ae851f5..0a03ab00b1 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -288,10 +288,10 @@ fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000); } diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index e35fe5453b..2c9889f5de 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -18,9 +18,9 @@ its current location. Opening the controls does not add a document undo step. | Create | Fills / Strokes | Creates filled areas, stroked lines, or both. | | Ignore Color | Color picker | Leaves out the selected color (white by default). | -The built-in presets other than Auto Color and Flat Logo all use Paths 50. Corners is 100 in -Technical Drawing, 75 in Default, Black and White Logo, Line Art and Outline, and 50 in the others. -Noise is 1 px in Technical Drawing, 5 in High Fidelity Photo, 10 in Low Fidelity Photo, 15 in 3 Colors, +The built-in presets other than Auto Color and Flat Logo all use Paths 50, and so do their enhanced +versions. Among those presets, Corners is 100 in Technical Drawing, 75 in Default, Black and White +Logo, Line Art and Outline, and 50 in the other eight. Noise is 1 px in Technical Drawing, 5 in High Fidelity Photo, 10 in Low Fidelity Photo, 15 in 3 Colors, 6 Colors, 16 Colors and Shades of Gray, 20 in Sketched Art, Line Art and Outline, 25 in Default and Black and White Logo, and 90 in Silhouettes (see [image-trace-quality.md](image-trace-quality.md#preset-paths-corners-and-noise)). diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 0c18767785..41262be69c 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -247,7 +247,8 @@ Every built-in preset other than Auto Color and Flat Logo traces at Paths 50. Co | Technical Drawing | 50 | 100 | 1 | 95, 90, 4 | | Outline | 50 | 75 | 20 | 80, 70, 10 | -The palettes, color counts, thresholds and other options of the presets did not change. +The palettes, color counts, thresholds and other options of the presets did not change. The enhanced +versions of these presets use the same Paths, Corners and Noise. Measured with the harness below (section 1, each preset on the inputs it is meant for), before and after: anchors fall in 10 of the 12 changed presets, for example 5,494 to 3,136 (High Fidelity Photo), 33 to 27 @@ -255,14 +256,17 @@ anchors fall in 10 of the 12 changed presets, for example 5,494 to 3,136 (High F Sketched Art does not change. Outlines on flat art lie further from the true edge: off mean 0.100 to 0.123 px (16 Colors), 0.101 to 0.119 (6 Colors), 0.104 to 0.129 (3 Colors), 0.064 to 0.092 (Black and White Logo) and 0.056 to 0.074 (Line Art), and off max 1.00 to 1.16 px in 6 Colors and 16 Colors. Technical Drawing -leaves 1.4 specks (was 0.5), and Outline's stroked centre lines lie 0.095 px off (was 0.086) with 2.7 +leaves 1.4 specks (was 0.5), and Outline's stroked center lines lie 0.095 px off (was 0.086) with 2.7 anchors a stroke (was 3.0). ΔE changes by 0.01 or less except Line Art and Outline (2.62 and 2.61 to 2.66 and 2.67); edge ΔE rises in 8 of the 12, for example 4.79 to 5.25 (16 Colors), 3.98 to 4.46 (3 Colors) and 9.46 to 10.03 (Black and White Logo). On synthetic shapes and photos of 300 to 4,000 px traced outside this harness, High Fidelity Photo makes 41% fewer anchors and 16 Colors 20% fewer; the mean pixel difference of each trace changes by −0.23 to +0.26 of a level; 16 Colors leaves 41 staircases of steps of 2 px or more where it left 40, High -Fidelity Photo 440 where it left 391, and Low Fidelity Photo 96 where it left 77. The tables below are the previous -measurement, not a rerun. +Fidelity Photo 440 where it left 391, Low Fidelity Photo 96 where it left 77, Line Art 17 where it left 13, Outline 13 +where it left 10 and 3 Colors 14 where it left 12. On the same images Sketched Art makes 4,084 anchors where it made +3,936 and Silhouettes 1,608 where it made 1,578. The figures for these twelve presets and their enhanced versions in +the other sections of this file, and in the Image Trace entries of +[target-app-parity.md](target-app-parity.md), were measured at the earlier values. Test: `presets_have_their_paths_corners_and_noise` in `crates/trace/src/tests.rs`. ## A simpler contour walk diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b59be1312b..529faa455f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1418,7 +1418,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, cut-out edges:** in Color and Grayscale, a semi-transparent pixel (alpha 128 to 254) on the edge of a cut-out image, beside a pixel that is less than half opaque, traces as a color of the fully opaque pixels within two pixels of it when its own color is that color or a darker shade of it, and of several such colors, as the one the most of those pixels have (art across a transparent gap does not count), so a darkened edge tone no longer traces as a 1 px sliver of an unrelated palette color, while a thin semi-transparent outline of another color keeps its own; code-drawn cut-out, opaque-edge and thin-outline tests in `crates/trace`. - **Image Trace, cut-out edges choose no color:** in Color and Grayscale, the edge pixels that take the color beside them, and the semi-transparent pixels within two pixels of them whose color is also the opaque art's color blended with the background, do not count when the palette or the grays are chosen, so the edge of a cut-out image no longer adds a second shade of a color to Auto Color's palette, takes away an outline's color or takes a gray of its own; beside those pixels, the anti-aliasing between a color and an outline drawn along the edge is no color of Auto Color's own when it lies at least 0.45 of the way from the color to the outline and at least one in 32 of its pixels has both around it (edge pixels that choose no color count when they lie within 2.5 Oklab × 100 of the nearest of those colors): its pixels trace as the outline, so a thin outline stays a ring of few pieces, except beside an outline along the edge with a region of 8 pixels of its own, where each takes the nearer of the two, so a wider outline is not drawn wider; an outline over the edge in the color of other art traces in that color, while an opaque rim drawn along the edge keeps its color, also where other art reaches it; a thin semi-transparent outline of another color still counts, and opaque images trace as before; code-drawn cut-out outline, rim, opaque line and grayscale edge tests in `crates/trace`. - **Image Trace, blurred edges:** in Color with a Limited palette of at most 16 colors (3 Colors, 6 Colors, 16 Colors) and in Grayscale with at most 16 grays (Shades of Gray), by VectorCraft's own rule (RGB distances, 0 to 255 a channel), a blend pixel with no unblended pixel of its own color within 4 pixels takes the nearer side of the pair of colors within 8 pixels that it is best a blend of, of the 8 colors within 8 pixels nearest to its color, when that pair explains it twice as well as its own color, or when it lies between the two and off the line between them by less than 0.15 of their distance apart; it keeps its color when it is one of the pair. Grayscale compares lightness. The in-between tones of an edge blurred over several pixels no longer trace as rings around it. High Fidelity Photo, Low Fidelity Photo and Black and White trace as before, and so do Automatic, Full Tone and Document Library palettes. Noisy and textured images settle more of their pixels. By `crates/trace/tests/quality` (256 px variants), 16 Colors on blurred flat art went from 9.0 paths to 3.0, 158 anchors to 34 and 0.239 px off to 0.069, and 6 Colors from 6.0 paths to 3.0 and 0.226 px off to 0.071; askew shapes take up to 11 px more area in colors the art does not have, and with 16 Colors the largest distance from a thin line's outline to the trace goes from 23.5 px to 45.3 (see [quality notes](image-trace-quality.md#blurred-edges)). Code-drawn blurred-disc and grayscale ramp tests in `crates/trace`. -- **Image Trace, preset Paths, Corners and Noise:** every built-in preset but Auto Color and Flat Logo traces at Paths 50; Corners is 100 in Technical Drawing, 75 in Default, Black and White Logo, Line Art and Outline and 50 in the others; Noise is 1 to 90 px by preset. Palettes and color counts are unchanged. By `crates/trace/tests/quality`, High Fidelity Photo goes from 5,494 anchors to 3,136 and Black and White Logo from 27 to 14, while 16 Colors on flat art goes from 0.100 px off to 0.123 (see [quality notes](image-trace-quality.md#preset-paths-corners-and-noise)). Test `presets_have_their_paths_corners_and_noise` in `crates/trace`. +- **Image Trace, preset Paths, Corners and Noise:** every built-in preset but Auto Color and Flat Logo traces at Paths 50, and so do their enhanced versions; among those presets, Corners is 100 in Technical Drawing, 75 in Default, Black and White Logo, Line Art and Outline and 50 in the other eight, and Noise is 1 to 90 px by preset. Palettes and color counts are unchanged. Figures for these presets in the other Image Trace entries were measured at the earlier values. By `crates/trace/tests/quality`, High Fidelity Photo goes from 5,494 anchors to 3,136 and Black and White Logo from 27 to 14, while 16 Colors on flat art goes from 0.100 px off to 0.123 (see [quality notes](image-trace-quality.md#preset-paths-corners-and-noise)). Test `presets_have_their_paths_corners_and_noise` in `crates/trace`. - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. From 8fb2897c3608c4c51a8facaeffe6e81e737ff1fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:31:32 -0800 Subject: [PATCH 18/82] i18n: Ukrainian translates 114 messages that no catalog had a row for The untranslated report listed 167 status and error messages that none of the 23 complete-message catalogs translates. The Ukrainian catalog now has an @msg row for 114 of them: the errors of the image encoders, color profiles, swatch exchange files and color books, 3D and Revolve, path operations, DXF import, the script engine and the parameter checks. Placeholders, backticked names, accepted values and limits stay as the English writes them. Open #7211, #7378 and #7405 translate the other 53. --- crates/ui-egui/src/i18n/tests.rs | 37 ++++++++++ crates/ui-egui/src/i18n/uk.tsv | 116 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 154 insertions(+) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 705fc68db2..4b2eebcf6b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1870,6 +1870,43 @@ fn ukrainian_plurals_have_three_forms() { assert!(count >= 13, "Ukrainian plural rows missing"); } +/// Ukrainian shows the image encoders', color profiles', swatch exchange files', Revolve's, DXF +/// import's, the script engine's and the parameter checks' errors in Ukrainian, with their values, +/// backticked names and accepted values kept as the English writes them. +#[test] +fn ukrainian_translates_encoder_profile_swatch_revolve_and_parameter_errors() { + for (english, ukrainian) in [ + ( + "PNG encoding failed: 0 × 0 pixels is not a valid image size", + "Не вдалося закодувати PNG: 0 × 0 пікселів не є допустимим розміром зображення", + ), + ( + "70000 × 10 pixels is too large for GIF (at most 65535 pixels a side): lower the resolution", + "розмір 70000 × 10 пікселів завеликий для GIF (не більше 65535 пікселів на сторону): зменште роздільну здатність", + ), + ("profile colour space XYZ is not RGB, CMYK or Gray", "колірний простір профілю XYZ не є RGB, CMYK чи Gray"), + ( + "color books in color space HSB aren't supported (only RGB, CMYK and Lab)", + "книги кольорів у колірному просторі HSB не підтримуються (лише RGB, CMYK і Lab)", + ), + ("the swatch exchange file holds more than 65536 colors and groups", "файл обміну зразками містить понад 65536 кольорів і груп"), + ( + "Revolve surface is too complex; reduce Segments or simplify the profile", + "Обертання: поверхня надто складна; зменште «Сегменти» або спростіть профіль", + ), + ("regular expression too large", "регулярний вираз завеликий"), + ("this binary DXF file is damaged or cut short", "цей двійковий файл DXF пошкоджено або обрізано"), + ( + "`view` is smallThumb, mediumThumb, largeThumb, smallList or largeList", + "`view` має бути smallThumb, mediumThumb, largeThumb, smallList або largeList", + ), + ("give `fill` or `stroke`, not both", "укажіть `fill` або `stroke`, але не обидва"), + ("stop 2: oops", "точка переходу 2: oops"), + ] { + assert_eq!(message(uk(), english), ukrainian); + } +} + /// Catalogs written with spaces between words keep a fragment's leading and trailing spaces: the /// hint bar and a few labels are joined from pieces (" to finish", "Press "). #[test] diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50690fa0c7..b3ab356d8c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6989,6 +6989,122 @@ breakText Glyphs Гліфи @msg only an exporter or an importer declares `extensions` `extensions` оголошує лише модуль експорту або імпорту @msg an importer always gets an import's budget: leave `budget` out модуль імпорту завжди отримує бюджет імпорту: не вказуйте `budget` +# Messages of the image encoders, color profiles, swatch exchange files and color books, 3D and Revolve, path operations, DXF import, the script engine and parameter checks. +@msg 3D effects require finite coordinates within 100 million points 3D-ефектам потрібні скінченні координати в межах 100 мільйонів пунктів +@msg 3D effects require one to ten lights 3D-ефектам потрібно від одного до десяти джерел світла +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Глибина BMP {_1}: 1, 4, 8, 16, 24 або 32 біти на піксель +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Не вдалося закодувати BMP: {width} × {height} пікселів не вміщується у формат або не відповідає буферу пікселів +@msg GIF encoding failed: the palette image is malformed Не вдалося закодувати GIF: зображення з палітрою має неправильну структуру +@msg GIF encoding failed: {e} Не вдалося закодувати GIF: {e} +@msg JPEG colour profile: {e} Профіль кольору JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Не вдалося закодувати JPEG: буфер пікселів не відповідає розміру зображення +@msg JPEG encoding failed: {e} Не вдалося закодувати JPEG: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Не вдалося закодувати PNG: буфер пікселів не відповідає розміру зображення +@msg PNG encoding failed: the palette image is malformed Не вдалося закодувати PNG: зображення з палітрою має неправильну структуру +@msg PNG encoding failed: {e} Не вдалося закодувати PNG: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Не вдалося закодувати PNG: {width} × {height} пікселів не є допустимим розміром зображення +@msg PSD colour profile: {e} Профіль кольору PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Не вдалося закодувати PSD: буфер пікселів не відповідає розміру зображення +@msg Revolve profile contains invalid coordinates Обертання: профіль містить неприпустимі координати +@msg Revolve profile is too complex Обертання: профіль надто складний +@msg Revolve projection is not finite Обертання: проєкція не скінченна +@msg Revolve requires finite profile coordinates within 100 million points Обертання: потрібні скінченні координати профілю в межах 100 мільйонів пунктів +@msg Revolve surface is too complex; reduce Segments or simplify the profile Обертання: поверхня надто складна; зменште «Сегменти» або спростіть профіль +@msg Revolve surface is too complex; simplify the profile Обертання: поверхня надто складна; спростіть профіль +@msg Revolve visibility cannot split the projected boundary Обертання: обчислення видимості не може розділити спроєктовану межу +@msg Revolve visibility creates too many fragments Обертання: обчислення видимості створює забагато фрагментів +@msg Revolve visibility creates too many vector anchors Обертання: обчислення видимості створює забагато векторних опорних точок +@msg Revolve visibility exceeds its geometry work limit Обертання: обчислення видимості перевищує межу геометричних обчислень +@msg Revolve visibility exceeds its spatial index limit Обертання: обчислення видимості перевищує межу просторового індексу +@msg Revolve visibility has too many boundary crossings Обертання: обчислення видимості має забагато перетинів меж +@msg Revolve visibility has too many input anchors Обертання: обчислення видимості має забагато вхідних опорних точок +@msg Revolve visibility requires convex cap sectors Обертання: обчисленню видимості потрібні опуклі сектори кришки +@msg Revolve visibility requires finite projected faces Обертання: обчисленню видимості потрібні скінченні спроєктовані грані +@msg SVG options: {e} Параметри SVG: {e} +@msg Save for Web settings: {e} Параметри «Зберегти для Інтернету»: {e} +@msg TIFF colour profile: {e} Профіль кольору TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Не вдалося закодувати TIFF: буфер пікселів не відповідає розміру зображення +@msg TIFF encoding failed: {e} Не вдалося закодувати TIFF: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Глибина Targa {_1}: 16, 24 або 32 біти на піксель +@msg Targa encoding failed: the pixel buffer doesn't match the image size Не вдалося закодувати Targa: буфер пікселів не відповідає розміру зображення +@msg The 3D projection is not finite 3D-проєкція не скінченна +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D-тінь надто складна; вимкніть «Тіні» або спростіть ілюстрацію +@msg The shape contains invalid coordinates Фігура містить неприпустимі координати +@msg Too many Revolve faces for visibility evaluation Забагато граней обертання для обчислення видимості +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft читає з файлу обміну зразками не більше {ASE_MAX_ENTRIES} кольорів і груп, а в цій бібліотеці їх {entries} +@msg WebP encoding failed: {e} Не вдалося закодувати WebP: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` має бути smallThumb, mediumThumb, largeThumb, smallList або largeList +@msg a line can't run from a point to itself лінія не може йти з точки в ту саму точку +@msg a line needs at least two points лінії потрібні щонайменше дві точки +@msg a number cannot be read не вдалося прочитати число +@msg a swatch exchange block is too long блок файлу обміну зразками задовгий +@msg a swatch exchange name is too long назва у файлі обміну зразками задовга +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels розмір зображення {sw} × {sh} не можна змінити на {width} × {height} пікселів +@msg a {width} × {height} image is too large зображення {width} × {height} завелике +@msg a {width} × {height} image: {e} зображення {width} × {height}: {e} +@msg an image could not be written: {e} не вдалося записати зображення: {e} +@msg an image that couldn't be read was left out зображення, яке не вдалося прочитати, пропущено +@msg can't open “{name}”: {e} не вдалося відкрити «{name}»: {e} +@msg cannot align zip entries to {align} bytes не вдається вирівняти записи архіву zip до {align} байтів +@msg cannot open the automation {kind} root `{_1}`: {e} не вдається відкрити кореневу папку автоматизації ({kind}) `{_1}`: {e} +@msg cannot read profile: {0} не вдалося прочитати профіль: {0} +@msg color book version {version} isn't supported (only 1) версія книги кольорів {version} не підтримується (лише 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) книги кольорів у колірному просторі {other} не підтримуються (лише RGB, CMYK і Lab) +@msg file name too long to zip: {_1} назва файлу задовга для архіву zip: {_1} +@msg file name too long to zip: {name} назва файлу задовга для архіву zip: {name} +@msg freeform line {i}: {e} довільна лінія {i}: {e} +@msg freeform point {i}: {e} довільна точка {i}: {e} +@msg give `{key}` or `{other}`, not both укажіть `{key}` або `{other}`, але не обидва +@msg grayscale profiles can't be working spaces yet профілі відтінків сірого поки що не можуть бути робочими просторами +@msg input contains NaN or infinite coordinates вхідні дані містять координати NaN або нескінченні координати +@msg input path could not be closed не вдалося замкнути вхідний контур +@msg invalid ICC profile: {0} неприпустимий профіль ICC: {0} +@msg invalid scale {scale} неприпустимий масштаб {scale} +@msg its localized names take more than {_1} KB його локалізовані назви займають понад {_1} КБ +@msg its objects are nested too deep ({e}) його об’єкти мають завелику вкладеність ({e}) +@msg its tag table lists the `{_1}` tag more than once його таблиця тегів містить тег `{_1}` більше одного разу +@msg not a graphic style library: {e} це не бібліотека графічних стилів: {e} +@msg not a swatch library (.vcswatches or .gpl) це не бібліотека зразків (.vcswatches або .gpl) +@msg not a swatch library (format `{_1}`) це не бібліотека зразків (формат `{_1}`) +@msg not a swatch library: {e} це не бібліотека зразків: {e} +@msg not an action set file ({e}) це не файл набору дій ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray колірний простір профілю {other} не є RGB, CMYK чи Gray +@msg regular expression too large регулярний вираз завеликий +@msg script file is not UTF-8 text: {value} файл скрипта не є текстом UTF-8: {value} +@msg stop {i}: {e} точка переходу {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) версія файлу обміну зразками {major}.{minor} не підтримується (лише 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors книга кольорів містить понад {ASE_MAX_ENTRIES} кольорів +@msg the color book is cut short книгу кольорів обрізано +@msg the color book's names take more than {_1} MB назви книги кольорів займають понад {_1} МБ +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution зображення мало б розмір {w} × {h} пікселів; растровий експорт обмежено {MAX_RASTER_SIDE} пікселями на сторону та {_1} мегапікселями: зменште масштаб або роздільну здатність +@msg the optimised image can't be read back: {e} оптимізоване зображення не вдається прочитати знову: {e} +@msg the pixel buffer doesn't match the image size буфер пікселів не відповідає розміру зображення +@msg the preview: {e} попередній перегляд: {e} +@msg the request may not read the New Document profiles folder запит не має права читати папку профілів нового документа +@msg the shapes are too degenerate to combine фігури занадто вироджені для об’єднання +@msg the shapes cross themselves and each other too often to combine фігури надто часто перетинають самі себе й одна одну, щоб їх можна було об’єднати +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups файл обміну зразками містить понад {ASE_MAX_ENTRIES} кольорів і груп +@msg the swatch exchange file is cut short файл обміну зразками обрізано +@msg the {_1} plate can't be read: {e} не вдалося прочитати форму {_1}: {e} +@msg this binary DXF file is damaged or cut short цей двійковий файл DXF пошкоджено або обрізано +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that це креслення DWG, яке неможливо прочитати: збережіть його з CAD як DXF і відкрийте його +@msg too many classes забагато класів +@msg too many groups забагато груп +@msg too many layers for PSD (at most 32767) забагато шарів для PSD (не більше 32767) +@msg too many swatch exchange blocks забагато блоків обміну зразками +@msg unknown colour profile: {0} невідомий профіль кольору: {0} +@msg {_1} clashes with a built-in profile name {_1} збігається з назвою вбудованого профілю +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution розмір {_1} × {_2} пікселів завеликий для GIF (не більше 65535 пікселів на сторону): зменште роздільну здатність +@msg {_1}: no usable transform to/from sRGB {_1}: немає придатного перетворення в sRGB або з sRGB +@msg {name} is not a CMYK profile {name} не є профілем CMYK +@msg {name} is not an RGB profile {name} не є профілем RGB +@msg {path}: could not be read {path}: не вдалося прочитати +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution розмір {width} × {height} пікселів завеликий для JPEG (не більше 65535 пікселів на сторону): зменште роздільну здатність +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution розмір {width} × {height} пікселів завеликий для Targa (не більше 65535 пікселів на сторону): зменште роздільну здатність +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution розмір {w} × {h} пікселів завеликий для PSD (не більше {MAX_SIDE} пікселів на сторону): зменште роздільну здатність + # Copy Troubleshooting Info Copy Troubleshooting Info Копіювати відомості для усунення несправностей @msg Copied the troubleshooting info Відомості для усунення несправностей скопійовано diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..b11e672e65 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -903,6 +903,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Edit › Undo and Edit › Redo, in the in-window and native menus, name the step in the interface language as the History panel lists it (`panels::history::step_name`), so when the catalog has no plain row for a step named after an effect, such as Feather, the menu shows the translation of the effect's menu row without its ellipsis (`undo_and_redo_name_the_step_as_the_history_panel_does`). - 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. + - Ukrainian messages (2026-11-13): the Ukrainian catalog (`crates/ui-egui/src/i18n/uk.tsv`) has an `@msg` row for 114 of the 167 status and error messages that the untranslated report (`untranslated_report`) lists as missing in all 23 complete-message languages: the errors of the image encoders, color profiles, swatch exchange files and color books, 3D and Revolve, path operations, DXF import, the script engine and the parameter checks. Open #7211, #7378 and #7405 translate the other 53 (the composite font, SVG filter and read-root errors). The rows keep the placeholders, backticked names, accepted values and limits of their English (`ukrainian_translates_encoder_profile_swatch_revolve_and_parameter_errors`); native review remains; scores and estimates unchanged. - 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 e47975d302153fda30a444a686b730f28f4fdda7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:32:02 -0800 Subject: [PATCH 19/82] Type: .ai import reads Insert Aki, and .ai save writes it Opening an .ai file now reads Insert Aki Left and Right from its text document through the same cascade as the other character keys: the run, its character style and the styles that one is based on, [Normal Character Style], then the document's defaults. The values are read in thousandths of an em. A negative value or one that is not a number opens as 0, and a value past 1000 opens as 1000. How the text document stores these values is VectorCraft's own rule. An .ai save writes each run's nonzero values in the form that reader reads, and no longer reports Insert Aki as left out of the editing data. A legacy .ai text object has no Insert Aki: type with it that is written as text now reports that the setting is left out. Type written as outlines keeps the space. --- crates/eps/src/ai_editing/tests.rs | 51 +++++++++++++++++++ crates/eps/src/ai_editing/writer/texts.rs | 19 ++++++- crates/eps/src/import/ate.rs | 60 +++++++++++++++++++++++ crates/eps/src/legacy_ai.rs | 2 +- crates/eps/src/legacy_ai/encoding.rs | 3 ++ crates/eps/src/legacy_ai/tests.rs | 35 +++++++++++++ docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 8 files changed, 173 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 967c982ad4..84f093695c 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4139,6 +4139,57 @@ fn standard_vertical_roman_alignment_comes_back() { assert_eq!(flags(&texts(&read_back(&both))[0])[2], (false, true)); } +/// Insert Aki Left and Right come back on each run, in vertical and horizontal type: a run with +/// either writes it as key 49 or 50 in 1/1000 em, a run without writes neither, and nothing is +/// reported. Each object lays out as it did. A run that also has an attribute the text document +/// leaves out is still reported. +#[test] +fn insert_aki_comes_back() { + use vectorcraft_doc::{CharStyle, TextObject, TextRun}; + let plain = style(14.0, Color::BLACK); + let left = CharStyle { aki_left: 250.0, ..plain.clone() }; + let both = CharStyle { aki_left: 125.5, aki_right: 500.0, ..plain.clone() }; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let mut down = TextObject::point(vectorcraft_geom::Point::new(150.0, 20.0), "", plain.clone()); + down.runs = vec![TextRun::new("AB", left.clone()), TextRun::new("CD", plain.clone()), TextRun::new("EF", both.clone())]; + down.vertical = true; + let mut across = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "", plain.clone()); + across.runs = vec![TextRun::new("GH", both.clone()), TextRun::new("IJ", plain.clone())]; + for t in [down, across] { + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&type_lines(&back), &type_lines(&doc)); + let texts = |d: &Document| -> Vec { + d.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).collect() + }; + let aki = |t: &TextObject| t.runs.iter().map(|r| (r.style.aki_left, r.style.aki_right)).collect::>(); + let (written, read) = (texts(&doc), texts(&back)); + assert_eq!(read.iter().map(aki).collect::>(), [vec![(250.0, 0.0), (0.0, 0.0), (125.5, 500.0)], vec![(125.5, 500.0), (0.0, 0.0)]]); + let origins = |t: &TextObject| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| (r3(g.origin.x), r3(g.origin.y))).collect::>() + }; + for (w, r) in written.iter().zip(&read) { + assert_eq!(origins(r), origins(w), "vertical={}", w.vertical); + } + let decoded = text_document(&program(&doc)); + for (key, count) in [("/49 250.0", 1), ("/49 125.5", 2), ("/50 500.0", 2)] { + assert_eq!(decoded.matches(key).count(), count, "{key}: {decoded}"); + } + assert_eq!(decoded.matches("/49 ").count() + decoded.matches("/50 ").count(), 5, "{decoded}"); + // Underline is still left out, and reported. + let mut lossy = doc.clone(); + let id = lossy.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = lossy.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.underline = true; + } + assert_eq!(export_ai_editing(&lossy, &opts()).unwrap().warnings, [writer::TYPE_ATTRIBUTES]); +} + /// The text document of program `text` (LF line ends), decoded, with its UTF-16 strings' zero /// bytes left out. fn text_document(text: &str) -> String { diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 6bc3c63b09..01b49d40c1 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -20,6 +20,7 @@ use std::fmt::Write; use kurbo::{ParamCurveArclen, PathSeg}; use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::MAX_AKI; use vectorcraft_doc::{ AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign, @@ -29,7 +30,7 @@ use vectorcraft_geom::{Affine, PathData, Point}; use crate::ps; /// What the text document holds of type, and what it leaves out. -pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; +pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, Insert Aki, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; /// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it. const STREAM_VERSION: u32 = 14; @@ -324,6 +325,12 @@ fn end_read_as_none(t: &TextObject) -> bool { !closed(&segs) && end < 1.0 - 1e-9 && place_on(&segs, end) >= segs.len() as f64 - 0.02 } +/// Insert Aki Left or Right `v` as the text document holds it: 0 to [`MAX_AKI`] in 1/1000 em, and 0 +/// for a value that is not a number. +fn held_aki(v: f64) -> f64 { + if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) } +} + /// Has type `t` attributes the text document leaves out ([`TYPE_ATTRIBUTES`])? pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // Small capitals are read back at Document Setup's default size. @@ -358,6 +365,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { tate_chu_yoko: s.tate_chu_yoko, // Key 35 holds one of the two: a Tate-chu-yoko run is read back without the other. standard_vertical_roman: s.standard_vertical_roman && !s.tate_chu_yoko, + aki_left: held_aki(s.aki_left), + aki_right: held_aki(s.aki_right), ..CharStyle::default() } }; @@ -675,6 +684,14 @@ impl TextDoc { }; d.push((12, V::Int(caps))); d.push((35, V::Int(baseline_direction(st)))); + // Insert Aki Left and Right in 1/1000 em, as the reader reads keys 49 and 50. No key is no + // space added: neither the stories' character attributes nor `[Normal Character Style]` set one. + for (key, v) in [(49, st.aki_left), (50, st.aki_right)] { + let v = held_aki(v); + if v > 0.0 { + d.push((key, real(v))); + } + } let fill = solid(&st.fill).flatten(); // A stroke of no width keeps its paint. let stroke = solid(&st.stroke).flatten(); diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 8d01b141d0..844cbe9f53 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -25,6 +25,7 @@ use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::MAX_AKI; use vectorcraft_doc::{ CharStyle, HyphenationOptions, JustificationOptions, Justify, LineCap, LineJoin, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, @@ -489,6 +490,17 @@ struct Style { /// Key 35 at 1 (Standard): Standard Vertical Roman Alignment, half-width characters upright in /// a column. Any other value, or no key in the run, its character style or the defaults, is off. standard_vertical_roman: bool, + /// Keys 49 and 50, Insert Aki Left and Right ([`CharStyle::aki_left`]), in 1/1000 em. + aki_left: f64, + aki_right: f64, +} + +/// Insert Aki Left or Right as key `v` of a character style gives it, in 1/1000 em (the unit the +/// scripting reference gives `aki left` and `aki right`), 0 to [`MAX_AKI`]. A negative value, one +/// that is not a finite number, or no key is 0: no space added. A key the run itself carries wins +/// over its styles' even when it is not a number, so a bad value never shows a style's. +fn aki(v: Option<&Val>) -> f64 { + v.and_then(Val::num).filter(|v| v.is_finite() && *v > 0.0).map_or(0.0, |v| v.min(MAX_AKI)) } /// The longest font version string read from a font record: name ID 5 strings are short (`Version @@ -852,6 +864,8 @@ impl Texts { caps: num("12").filter(|v| *v == 1.0 || *v == 2.0).map_or(0, |v| v as u8), tate_chu_yoko: num("35") == Some(3.0), standard_vertical_roman: num("35") == Some(1.0), + aki_left: aki(pick("49")), + aki_right: aki(pick("50")), } } } @@ -1470,6 +1484,9 @@ fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { small_caps: (s.caps == 1).then(|| vectorcraft_doc::DocSetup::default().small_caps_size), tate_chu_yoko: s.tate_chu_yoko, standard_vertical_roman: s.standard_vertical_roman, + // In ems of the type's own size, so the art's scale does not apply. + aki_left: s.aki_left, + aki_right: s.aki_right, ..CharStyle::default() } } @@ -1754,6 +1771,47 @@ mod tests { assert!(texts(b"/35 3").style(None).tate_chu_yoko && !texts(b"/35 3").style(None).standard_vertical_roman); } + /// Keys 49 and 50 of a character style are Insert Aki Left and Right in 1/1000 em, each from the + /// run, its character style and the styles that is based on, `[Normal Character Style]`, then the + /// document's defaults. A run's own 0 wins over a style's value, a value past 1000 is 1000, and a + /// negative value, one that is not a number or no key at all is 0. + #[test] + fn a_run_keeps_its_insert_aki() { + let texts = |defaults: &[u8], normal: &[u8]| Texts { + stories: vec![], + fonts: vec![], + defaults: read(defaults).unwrap(), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![ + read(&[&b"/0 ([Normal Character Style]) /6 << "[..], normal, b" >>"].concat()).unwrap(), + read(b"/0 (Base) /6 << /49 125.0 >>").unwrap(), + read(b"/0 (Spaced) /5 1 /6 << /50 500.0 >>").unwrap(), + ], + para_sheets: vec![], + frames: vec![], + patterns: HashMap::new(), + }; + let aki = |defaults: &[u8], normal: &[u8], run: &[u8]| { + let s = texts(defaults, normal).style(Some(&read(run).unwrap())); + (s.aki_left, s.aki_right) + }; + assert_eq!(aki(b"/0 0", b"", b"/6 << /49 250.0 /50 750.0 >>"), (250.0, 750.0), "the run's own"); + assert_eq!(aki(b"/0 0", b"", b"/5 2 /6 << >>"), (125.0, 500.0), "its style's, and the style that is based on"); + assert_eq!(aki(b"/0 0", b"", b"/5 2 /6 << /49 0.0 /50 0 >>"), (0.0, 0.0), "the run's own zero wins"); + assert_eq!(aki(b"/49 10.0 /50 20.0", b"/49 30.0", b"/6 << >>"), (30.0, 20.0), "Normal, then the document's"); + assert_eq!(aki(b"/49 10.0 /50 20.0", b"", b"/6 << >>"), (10.0, 20.0), "the document's"); + assert_eq!(aki(b"/0 0", b"", b"/6 << >>"), (0.0, 0.0), "no key"); + assert_eq!(aki(b"/0 0", b"", b"/6 << /49 1000.5 /50 1e300 >>"), (MAX_AKI, MAX_AKI), "clamped"); + for bad in [&b"-1.0"[..], b"-250", b"(125)", b"true", b"[ 125 ]", b"1e400", b"-1e400"] { + let run = [&b"/5 2 /6 << /49 "[..], bad, b" /50 ", bad, b" >>"].concat(); + assert_eq!(aki(b"/0 0", b"", &run), (0.0, 0.0), "{}", String::from_utf8_lossy(bad)); + } + // In ems of the type's size: the art's scale leaves it as it is. + let s = texts(b"/0 0", b"").style(Some(&read(b"/6 << /49 250.0 /50 125.0 >>").unwrap())); + let c = char_style(&s, 10.0, Affine::IDENTITY); + assert_eq!((c.aki_left, c.aki_right), (250.0, 125.0)); + } + /// Keys 61, 62 and 64 of a character style are its stroke's cap (0 butt, 1 round, 2 /// projecting), join (0 miter, 1 round, 2 bevel) and miter limit, from the run, its character /// style or the document's defaults. A value that isn't one of theirs keeps the default, a miter @@ -2224,6 +2282,8 @@ mod tests { caps: 0, tate_chu_yoko: false, standard_vertical_roman: false, + aki_left: 0.0, + aki_right: 0.0, }, )], kerns: vec![], diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 7e38b5ba51..76980f4b7e 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -101,7 +101,7 @@ const HEX_LINE: usize = 60; pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines"; pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)"; -pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; +pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, optical kerning, small caps, superscript and subscript, Insert Aki, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index 7ebae72123..d971e5249e 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -123,6 +123,9 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool { || s.optical_kerning || s.small_caps.is_some() || s.position != CharPosition::Normal + // Its text operators (§15.4) have no Insert Aki. + || s.aki_left != 0.0 + || s.aki_right != 0.0 || !s.features.is_empty() || !s.font_axes.is_empty() }; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 15d7d07152..55e0d06171 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1394,6 +1394,41 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// A legacy `.ai` text object has no Insert Aki (§15.4): type with Insert Aki Left or Right is +/// written as text with the warning that it is left out. Written as outlines, the glyphs keep the +/// space it adds, and nothing is reported. +#[test] +fn insert_aki_is_warned_as_left_out_of_text() { + for (left, right) in [(250.0, 0.0), (0.0, 125.0)] { + let mut doc = typed(); + let id = texts(&doc)[0].0.id; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.aki_left = left; + t.runs[0].style.aki_right = right; + } + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES), "{left} {right}: {:?}", out.warnings); + assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); + } + let width = |aki: f64| { + let mut st = style(20.0, red()); + st.aki_left = aki; + st.aki_right = aki; + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "HH", st); + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }); + assert!(!out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES), "{:?}", out.warnings); + let back = crate::import(&out.bytes).unwrap().document; + children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap().width() + }; + // Between the two H's: 0.5 em after the first and 0.5 em before the second, 20 pt. + let (plain, spaced) = (width(0.0), width(500.0)); + assert!((spaced - plain - 20.0).abs() < 0.05, "{plain} {spaced}"); +} + /// Standard Vertical Roman Alignment is written as `Tv` and Tate-chu-yoko in horizontal type as a /// Kumi block recorded for later use, `TY` (§15.3.4), each where it changes: the marks read back /// on the same characters, a block is closed before its text object ends, and type with neither diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..c84389ef33 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8741,7 +8741,10 @@ menus, and a value typed past 1000 sets 1000; Reset Panel sets both to 0. Charac in Advanced Character Formats. With Fractional Widths off or System Layout on, each side is set in whole points, and justification keeps that width. Live SVG text places each character the space moves, and type on a path with Insert Aki is written as outlines. An SVG saved without Preserve Editing (`preserveEditing`) reopens without the -Insert Aki values; save it with Preserve Editing to keep them editable. +Insert Aki values; save it with Preserve Editing to keep them editable. An `.ai` file's text document keeps both +values, and opening the file reads them back. The stored values are read in thousandths of an em and a negative +value opens as 0; these are VectorCraft's own rules. A legacy `.ai` save writes type with Insert Aki as text without the values and warns that they are left +out. ```json {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"akiRight":500}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..d292278490 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1381,6 +1381,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end. + Insert Aki Left and Right open from an `.ai` file's text document (read in thousandths of an em, the unit the [scripting reference](https://web.archive.org/web/20160410092022/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/pdf/illustrator/pdfs/illustrator_scripting_reference_applescript.pdf#page=90) gives `aki left` and `aki right`; VectorCraft's own rule for the stored value) from the run, its character style, the styles that one is based on, `[Normal Character Style]` and the document's defaults in that order. A negative value or one that is not a number opens as 0, and a value past 1000 opens as 1000. An `.ai` save writes each run's nonzero values in the form that reader reads. A legacy `.ai` save writes type with Insert Aki as text without it and reports that it is left out; type it writes as outlines keeps the space. The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. From 1b268a725ecf52338421fb8f778c10fadf05c9d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:38:10 -0800 Subject: [PATCH 20/82] 3D: a render with a shadow builds each solid once With Shadows on, Extrude & Bevel, Inflate, Rotate and Revolve built every solid twice: once for the art and again for the shadow. The build that draws a solid now also keeps the faces its shadow is cast from and the projection they were built with, and the shadow is cast from them. The art and the shadow are unchanged. - three-d: the solid builder can keep the projected facets and the view-space faces together (`Keep::Both`); `extrude_shadowed`, `plane_shadowed`, `inflate_shadowed` and `revolve_shadowed` return both, and `ShadowCaster::add_solid` adds the kept faces. Revolve's sweep draws the surface and keeps the shadow's faces in one pass; a shadow past its limits still gives the same error while the surface draws. - effects: the solids drawn hand their faces to the shadow. A stroke's solid left out of the art is still built for the shadow alone, as before. Tests compare each solid and its shadow with the two built apart, to the bit, and count the solids built for a shadowed object. --- crates/effects/src/revolve.rs | 174 +++++++++++-- crates/effects/src/solids.rs | 180 ++++++++++++-- crates/effects/src/solids/tests_shadow.rs | 89 +++++++ crates/three-d/src/lib.rs | 290 ++++++++++++++++------ crates/three-d/src/mesh.rs | 8 +- crates/three-d/src/shadow.rs | 74 +++++- crates/three-d/src/shadow/tests.rs | 241 ++++++++++++++++++ crates/three-d/src/solid.rs | 6 +- crates/three-d/src/solid/build.rs | 108 ++++++-- crates/three-d/src/solid/inflate.rs | 57 +++-- crates/three-d/src/solid/tests.rs | 4 +- docs/target-app-parity.md | 1 + 12 files changed, 1053 insertions(+), 179 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 74b23340cd..76829e7ba0 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; -use vectorcraft_three_d::{Edge, Revolve}; +use vectorcraft_three_d::{Edge, Revolve, SolidFaces}; use crate::solids::{ Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, @@ -337,34 +337,80 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// `b`, revolved with `options`, as dark as `item` is opaque and in its blending mode; `None` with /// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when /// the surface it is cast from is past the limits. -fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result, String> { +/// +/// `kept` holds the faces the surface drawn kept for its shadow ([`surface`]); without them the +/// surface is swept again for its shadow alone. +fn shadow_of( + id: NodeId, + fx: &Effect, + path: &PathData, + item: &AppearanceItem, + b: Rect, + options: Revolve, + kept: Option, +) -> Result, String> { let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(None) }; let (_, opacity, blend) = paint_of(item); if opacity <= 0.0 { return Ok(None); } let mut caster = vectorcraft_three_d::ShadowCaster::new(); - caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options)?; + cast_surface(&mut caster, path, b, options, kept)?; Ok(crate::solids::shadow::art(id, &caster, settings, crate::solids::shadow::Ink { opacity, blend })) } +/// Does Revolve `fx` cast a shadow, so a surface in paint as opaque as `opacity` keeps the faces +/// its shadow is cast from ([`surface`])? +fn casts(fx: &Effect, opacity: f32) -> bool { + opacity > 0.0 && crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)).is_some() +} + +/// Add the surface of `path` about the axis of `b` to `caster`: the faces it kept when it was +/// drawn, or without them, the surface swept for its shadow alone. +fn cast_surface( + caster: &mut vectorcraft_three_d::ShadowCaster, + path: &PathData, + b: Rect, + options: Revolve, + kept: Option, +) -> Result, String> { + match kept { + Some(kept) => caster.add_solid(kept), + None => { + #[cfg(test)] + crate::solids::SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options) + } + } +} + /// The shadow of group Revolve `fx` cast by the surfaces of the members `shapes` that have one, /// about the axis of `b`, on one plane; each member's part is as dark as its surface is opaque, /// in its paint's blending mode, and its opacity mask and blending mode apply to that part /// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the -/// limits. -fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result>, String> { +/// limits. `kept` holds, member by member, the faces each surface drawn kept for its shadow +/// ([`surface`]); a member without them is swept again for its shadow alone. +fn group_shadow( + id: NodeId, + fx: &Effect, + shapes: &[Profile], + b: Rect, + options: Revolve, + kept: Vec>, +) -> Result>, String> { let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(vec![]) }; let mut caster = vectorcraft_three_d::ShadowCaster::new(); let mut cast = vec![]; + let mut kept = kept.into_iter(); for s in shapes { + let kept = kept.next().flatten(); let Some((_, opacity, blend)) = surface_item(&s.items).map(paint_of) else { continue }; // A member whose surface is fully transparent casts nothing. let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend }; if ink.opacity <= 0.0 { continue; } - let faces = caster.revolve(&s.path, vectorcraft_geom::FillRule::NonZero, b, options)?; + let faces = cast_surface(&mut caster, &s.path, b, options, kept)?; cast.push((s.scopes.as_slice(), ink, faces)); } Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes))) @@ -374,7 +420,8 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R /// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face /// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the /// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): -/// a face with a part of a graphic is a group of the face and that part. +/// a face with a part of a graphic is a group of the face and that part. With `cast`, the sweep +/// that draws the surface also keeps the faces its shadow is cast from ([`SolidFaces`]). #[allow(clippy::too_many_arguments)] fn surface( doc: Option<&Document>, @@ -388,21 +435,32 @@ fn surface( blend: vectorcraft_doc::color::BlendMode, expand: bool, copies: &mut Copies, -) -> Option<(Vec, Node)> { + cast: bool, +) -> Option<(Vec, Node, Option)> { + #[cfg(test)] + crate::solids::SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { - let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?; + let (facets, kept) = if cast { + vectorcraft_three_d::revolve_facets_shadowed(path, b, options).ok().map(|(f, kept)| (f, Some(kept)))? + } else { + (vectorcraft_three_d::revolve_facets(path, b, options).ok()?, None) + }; copies.surfaces = copies.surfaces.saturating_add(paint_copies(facets.len(), &paint)); if copies.surfaces > MAX_PAINT_COPIES { return None; } let depths = facets.iter().map(|f| f.depth).collect(); let p = merged_params(&fx.id, &fx.params); - return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p), kept)); } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); - let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; + let (faces, kept) = if cast { + vectorcraft_three_d::revolve_shadowed(path, b, options).ok().map(|(f, kept)| (f, Some(kept)))? + } else { + (vectorcraft_three_d::revolve(path, b, options).ok()?, None) + }; copies.surfaces = copies.surfaces.saturating_add(paint_copies(faces.len(), &paint)); if copies.surfaces > MAX_PAINT_COPIES { return None; @@ -517,11 +575,12 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some((depths, group)) + Some((depths, group, kept)) } -/// A surface of one object about the bounds of `path`, built with options derived from `fx`. -/// `copies` is the count [`surface`] adds to; its graphics take the symbols of `doc`. +/// A surface of one object about the bounds of `path`, built with options derived from `fx`, and +/// when `fx` casts a shadow from it, the faces it is cast from ([`surface`]). `copies` is the count +/// [`surface`] adds to; its graphics take the symbols of `doc`. fn object_surface( doc: Option<&Document>, n: &Node, @@ -530,10 +589,11 @@ fn object_surface( item: &AppearanceItem, expand: bool, copies: &mut Copies, -) -> Option { +) -> Option<(Node, Option)> { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); - surface(doc, n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) + let cast = casts(fx, opacity); + surface(doc, n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies, cast).map(|(_, art, kept)| (art, kept)) } /// The paint of a graphic's solid color `c` on `face`, shaded as the face shades a solid fill of @@ -727,9 +787,9 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let surface = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; + let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; if let Some(b) = path.bounds() - && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()? + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()? { children.push(Arc::new(shadow)); } @@ -753,10 +813,10 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; + let (mut art, kept) = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() - && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()? + && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx), kept).ok()? { shadow.appearance.effects = art.appearance.effects.clone(); shadows.push(Arc::new(shadow)); @@ -905,21 +965,28 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut copies = Copies::default(); let mut surfaces = vec![]; + // The faces each member's surface kept for the shadow, member by member. + let mut kept = vec![]; let mut opaque = true; // The graphics go once, on the side of the member with the longest profile. Without graphics // no member is measured for them. let mapped = fx.id == REVOLVE && !crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)).is_empty(); let longest = if mapped { graphics_member(&shapes, b, options) } else { None }; for (k, s) in shapes.iter().enumerate() { - let Some(item) = surface_item(&s.items) else { continue }; + let Some(item) = surface_item(&s.items) else { + kept.push(None); + continue; + }; // The opacity and blending of the member and the groups it is in apply through its scopes. let (paint, opacity, blend) = paint_of(item); opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty(); // A member with transparency of its own keeps every face, as a transparent object does. let expand = expand && s.scopes.is_empty(); let doc = doc.filter(|_| longest == Some(k)); - let (depths, art) = surface(doc, n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; + let cast = casts(fx, opacity * s.opacity); + let (depths, art, faces) = surface(doc, n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies, cast)?; surfaces.push((depths, art, s.scopes.as_slice())); + kept.push(faces); } if surfaces.is_empty() { return None; @@ -1017,7 +1084,7 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { }; // One shadow for every member's surface, behind them all. let mut children = children; - children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); + children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options, kept).ok()?); let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; @@ -1642,6 +1709,67 @@ mod tests { } assert_eq!(expand_revolve_art(&sample), revolve_art(&sample)); } + + /// The settings the shadows are compared with: the defaults, a partial sweep turned with + /// perspective, and an open surface away from its axis under a colored light. + fn shadow_settings() -> [Value; 3] { + [ + json!({"shadow": true}), + json!({"shadow": true, "rotationX": -25, "rotationY": 35, "perspective": 50, "angle": 220, "shadowDistance": 40}), + json!({"shadow": true, "shadowDistance": 30, "lightColor": "#ff8040", "offset": 20, "cap": false}), + ] + } + + /// The shadow of the faces Revolve and Revolve (Classic) kept when they swept their surfaces, + /// to the bit: the shadow of surfaces swept for the shadow alone, for one object, for an + /// object whose fill is half transparent, and for a group of two members, one at half opacity. + #[test] + fn the_shadow_cast_from_the_surfaces_drawn_is_the_shadow_of_surfaces_swept_for_it() { + for id in [REVOLVE, CLASSIC_REVOLVE] { + for params in shadow_settings() { + // Revolve (Classic) takes no shadow settings from commands, but a file can carry them. + let mut fx = crate::new_effect(id, &json!({})).unwrap(); + fx.params = params.clone(); + let n = profile_node(); + let (path, _) = profile(&n).unwrap(); + let b = path.bounds().unwrap(); + let mut half = n.clone(); + if let AppearanceItem::Fill(f) = &mut half.appearance.items[0] { + f.opacity = 0.5; + } + for n in [n, half] { + let item = surface_item(&n.appearance.items).unwrap(); + let (_, kept) = object_surface(None, &n, &path, &fx, item, false, &mut Copies::default()).unwrap(); + let drawn = shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), kept).unwrap(); + let alone = shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), None).unwrap(); + assert!(alone.is_some() || id == CLASSIC_REVOLVE, "{id} {params}"); + assert_eq!(format!("{drawn:?}"), format!("{alone:?}"), "{id} {params}"); + } + let mut other = profile_node(); + other.id = NodeId(2); + other.transform(Affine::translate((120.0, 0.0)), false); + other.opacity = 0.5; + let mut g = Node::group(NodeId(9), vec![Arc::new(profile_node()), Arc::new(other)]); + g.appearance.effects.push(fx.clone()); + let shapes = members(&g, "").unwrap(); + let b = union_bounds(&shapes).unwrap(); + let options = shared_options(&shapes, fx_options(&fx)).unwrap(); + let kept: Vec> = shapes + .iter() + .map(|s| { + let (paint, opacity, blend) = paint_of(surface_item(&s.items).unwrap()); + let cast = casts(&fx, opacity * s.opacity); + surface(None, &g, &s.path, b, options, &fx, paint, opacity, blend, false, &mut Copies::default(), cast).unwrap().2 + }) + .collect(); + assert!(kept.iter().all(Option::is_some), "{id} {params}"); + let drawn = group_shadow(g.id, &fx, &shapes, b, options, kept).unwrap(); + let alone = group_shadow(g.id, &fx, &shapes, b, options, vec![]).unwrap(); + assert!(!alone.is_empty() || id == CLASSIC_REVOLVE, "{id} {params}"); + assert_eq!(format!("{drawn:?}"), format!("{alone:?}"), "{id} {params}"); + } + } + } } #[cfg(test)] diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7500af0617..7b7ad3b27b 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; use vectorcraft_pathops::BoolOp; -use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, SolidFaces, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -232,6 +232,26 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> } } +/// [`facets`], and with `cast` for Extrude & Bevel or Rotate the faces the solid's shadow is cast +/// from, from the same build. Inflate's solids are built together ([`built_solids_cast`]), and the +/// classic effects cast no shadow: they keep no faces. +fn facets_and_faces( + id: &str, + path: &PathData, + rule: FillRule, + bounds: Rect, + p: &Value, + cast: bool, +) -> Result<(Vec, Option), String> { + let view = view_options(p); + let built = match id { + EXTRUDE if cast => vectorcraft_three_d::extrude_shadowed(path, rule, bounds, extrude_options(p), view), + ROTATE if cast => vectorcraft_three_d::plane_shadowed(path, rule, bounds, view), + _ => return facets(id, path, rule, bounds, p).map(|f| (f, None)), + }; + built.map(|(f, faces)| (f, Some(faces))) +} + /// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not /// need the object. fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { @@ -656,8 +676,9 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { #[cfg(test)] thread_local! { - /// How many solids [`built_solids`] has built on this thread, for the tests. - static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How many solids [`built_solids`] has built on this thread, for the tests. Rotate's planes + /// drawn, the solids built to cast a shadow ([`cast_shadow`]) and Revolve's surfaces count too. + pub(crate) static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from @@ -673,10 +694,35 @@ fn built_solids<'a>( bounds: Rect, limit: usize, ) -> impl Iterator>, String>> + 'a { + built_solids_cast(id, p, parts, bounds, limit, false).map(|r| r.map(|s| s.map(|(f, _)| f))) +} + +/// One solid's facets and, when its shadow is cast, the faces it is cast from. +type CastingSolid = (Vec, Option); + +/// The surfaces of an object's solids, and the faces each solid kept for its shadow +/// ([`surfaces_for`]). +type SurfacesAndFaces = (Vec>, Vec>); + +/// [`built_solids`], and with `cast` for Extrude & Bevel or Inflate each solid's faces its shadow +/// is cast from, from the same build ([`SolidFaces`]). +fn built_solids_cast<'a>( + id: &'a str, + p: &'a Value, + parts: &'a [Part<'a>], + bounds: Rect, + limit: usize, + cast: bool, +) -> impl Iterator, String>> + 'a { // Inflate's eye is placed for every puff together, as far away as the highest needs. - let mut inflated = (id == INFLATE).then(|| { + let mut inflated = (id == INFLATE).then(|| -> Box>> { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_each(&shapes, bounds, inflate_options(p), view_options(p)) + let (o, view) = (inflate_options(p), view_options(p)); + if cast { + Box::new(vectorcraft_three_d::inflate_shadowed(&shapes, bounds, o, view).map(|r| r.map(|(f, faces)| (f, Some(faces))))) + } else { + Box::new(vectorcraft_three_d::inflate_each(&shapes, bounds, o, view).map(|r| r.map(|f| (f, None)))) + } }); let mut kept = 0usize; let mut ended = false; @@ -687,12 +733,12 @@ fn built_solids<'a>( #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); let built = match inflated.as_mut() { - Some(each) => each.next().unwrap_or_else(|| Ok(vec![])), - None => facets(id, area, *rule, bounds, p), + Some(each) => each.next().unwrap_or_else(|| Ok((vec![], None))), + None => facets_and_faces(id, area, *rule, bounds, p, cast), }; let result = match built { Ok(f) => { - kept = kept.saturating_add(f.len()); + kept = kept.saturating_add(f.0.len()); if kept > limit { Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()) } else { Ok(Some(f)) } } Err(_) if matches!(item, AppearanceItem::Stroke(_)) => Ok(None), @@ -1292,12 +1338,16 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it -/// off), to be drawn behind every surface of the object, and the surfaces. +/// off), cast from the faces the solids drawn kept ([`cast_built`]), to be drawn behind every +/// surface of the object, and the surfaces. fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { - let art = surfaces_for(doc, n, fx, shapes)?; + let p = crate::merged_params(&fx.id, &fx.params); + let cast = !is_classic_three_d(&fx.id) && shadow::options(&p).is_some(); + let (art, built) = surfaces_for(doc, n, fx, shapes, cast)?; // 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()? { + let shadow = if cast { cast_built(&fx.id, &p, shapes, built) } else { Ok(None) }; + let shadow = match shadow.ok()? { Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)), None => vec![], }; @@ -1330,6 +1380,8 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result 0.0) { let (area, rule) = region(&s.path, s.rule, item); + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); let faces = caster.plane(&area, rule, b, view)?; cast.push((s.scopes.as_slice(), ink(item, s.opacity), faces)); } @@ -1341,10 +1393,14 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result, String>> = if id == INFLATE { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); let casts: Vec = inks.iter().map(|i| i.opacity > 0.0).collect(); + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + shapes.len())); caster.inflate(&shapes, &casts, bounds, inflate_options(p), view) } else { let mut added = vec![]; for ((_, _, area, rule, _, _), ink) in parts.iter().zip(&inks) { + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + usize::from(ink.opacity > 0.0))); added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); } added @@ -1366,6 +1422,76 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result(id: &str, p: &Value, shapes: &'a [Profile], built: Vec>) -> Result>, String> { + if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { + return Ok(None); + } + let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) }; + let view = view_options(p); + let mut caster = vectorcraft_three_d::ShadowCaster::new(); + let mut cast: Vec = vec![]; + let ink = |item: &AppearanceItem, member: f32| { + let (_, opacity, blend) = paint_of(item, member); + shadow::Ink { opacity, blend } + }; + if id == ROTATE { + let items: Vec<(&Profile, &AppearanceItem)> = shapes.iter().flat_map(|s| s.items.iter().map(move |item| (s, item))).collect(); + if items.len() != built.len() { + return cast_shadow(id, p, shapes); + } + for ((s, item), kept) in items.into_iter().zip(built) { + let ink = ink(item, s.opacity); + if ink.opacity <= 0.0 { + continue; + } + let faces = match kept { + Some(kept) => caster.add_solid(kept)?, + None => { + let (area, rule) = region(&s.path, s.rule, item); + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + caster.plane(&area, rule, b, view)? + } + }; + cast.push((s.scopes.as_slice(), ink, faces)); + } + return Ok(Some((caster, settings, cast))); + } + let (parts, bounds) = solid_parts(shapes, b); + let inks: Vec = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect(); + if parts.len() != built.len() || (id == INFLATE && inks.iter().zip(&built).any(|(ink, kept)| ink.opacity > 0.0 && kept.is_none())) { + return cast_shadow(id, p, shapes); + } + for (((item, _, area, rule, scopes, _), ink), kept) in parts.iter().zip(inks).zip(built) { + if ink.opacity <= 0.0 { + continue; + } + let faces = match kept { + Some(kept) => caster.add_solid(kept), + None => { + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + caster.extrude(area, *rule, bounds, extrude_options(p), view) + } + }; + // A stroke whose own solid is past the limits is left out; past the shadow's limit for all + // the solids together, the shadow is refused. + let faces = match faces { + Ok(faces) => faces, + Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue, + Err(e) => return Err(e), + }; + cast.push((*scopes, ink, faces)); + } + Ok(Some((caster, settings, cast))) +} + /// The faces a shadow is cast from, its settings, and the faces of each solid of visible art with /// its member's scopes and ink ([`cast_shadow`]). type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec>); @@ -1507,8 +1633,10 @@ fn seen_ends(f: &[Facet]) -> impl Iterator { } /// [`art_for`] without the shadow: the solids' surfaces, with the graphics mapped onto their planar -/// faces drawn right after the last of the solids' surfaces that holds that face. -fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { +/// faces drawn right after the last of the solids' surfaces that holds that face. With `cast`, each +/// solid built also keeps the faces its shadow is cast from, from the same build, in the order the +/// solids are built ([`cast_built`]); `None` for a stroke's solid left out of the art. +fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile], cast: bool) -> Option { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; @@ -1523,11 +1651,15 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile // the centre of theirs), so they stay together. let mut out = vec![]; let mut filled = vec![]; + let mut built = vec![]; for (member, s) in shapes.iter().enumerate() { for item in &s.items { let (paint, opacity, blend) = paint_of(item, s.opacity); let (area, rule) = region(&s.path, s.rule, item); - let f = facets(ROTATE, &area, rule, b, &p).ok()?; + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + let (f, faces) = facets_and_faces(ROTATE, &area, rule, b, &p, cast).ok()?; + built.push(faces); if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) { return None; } @@ -1544,19 +1676,23 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile } let arts = placed_graphics(doc, n.id, (ROTATE, &p), b, &filled, masks); let out = with_graphics(out, 0, &arts); - return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); + return Some((if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }, built)); } let (parts, bounds) = solid_parts(shapes, b); let mut solids = vec![]; - for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES)) { + let mut built = vec![]; + for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built_solids_cast(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES, cast)) { let item: &AppearanceItem = item; match f { - Ok(Some(f)) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => { + Ok(Some((f, _))) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => { return None; } - Ok(Some(f)) => solids.push((item, *opacity, *scopes, f, *member)), + Ok(Some((f, faces))) => { + solids.push((item, *opacity, *scopes, f, *member)); + built.push(faces); + } // A stroke's outline past the limits is left out; the fill's solid still draws. - Ok(None) => {} + Ok(None) => built.push(None), // A loaded file or a shape edited past the limits (its own, or those of the object's // solids together) keeps drawing its source path instead of failing the frame; // commands validate the same evaluator and report its errors. @@ -1678,12 +1814,12 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); } } - return Some(masked(n.id, out)); + return Some((masked(n.id, out), built)); } if !opaque || !surfaces.iter().all(plain) { - return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); + return Some((whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect(), built)); } - 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)))], built)) } /// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 7e7d2e1595..4d62961612 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -223,3 +223,92 @@ fn expand_appearance_keeps_the_shadow_as_a_path() { assert!((after.x0 - before.x0 - 40.0).abs() < 1e-6, "{id}: {before:?} {after:?}"); } } + +/// A square filled red and stroked blue, its stroke as opaque as `stroke`. +fn stroked_square(id: u64, dx: f64, stroke: f32) -> Node { + let mut n = square_node(id, dx); + n.appearance = Appearance::basic(Paint::solid(RED), Paint::solid(Color::rgb(0.1, 0.2, 0.9)), 6.0).into(); + for item in &mut n.appearance.items { + if let AppearanceItem::Stroke(s) = item { + s.opacity = stroke; + } + } + n +} + +/// Objects with 3D effect `id` and its shadow on (`params`): a filled and stroked square, one whose +/// stroke is half transparent, one whose stroke is fully transparent, and a group of two members, +/// one of them at half opacity. +fn shadowed(id: &str, params: &Value) -> Vec { + let with = |mut n: Node| { + n.appearance.effects.push(crate::new_effect(id, params).unwrap()); + n + }; + let mut half = stroked_square(2, 200.0, 1.0); + half.opacity = 0.5; + let group = Node::group(NodeId(9), vec![Arc::new(stroked_square(1, 0.0, 1.0)), Arc::new(half)]); + vec![with(stroked_square(1, 0.0, 1.0)), with(stroked_square(1, 0.0, 0.5)), with(stroked_square(1, 0.0, 0.0)), with(group)] +} + +/// The settings the shadows of effect `id` are compared with: the defaults, turned with +/// perspective, and under a colored light, below the object for Extrude & Bevel, Inflate and +/// Rotate and behind it for Revolve. +fn shadow_settings(id: &str) -> [Value; 3] { + let position = if id == crate::REVOLVE { "behind" } else { "below" }; + [ + json!({"shadow": true}), + json!({"shadow": true, "rotationX": -25, "rotationY": 35, "perspective": 50, "shadowDistance": 40}), + json!({"shadow": true, "shadowPosition": position, "shadowDistance": 30, "lightColor": "#ff8040", "twist": 30}), + ] +} + +/// How many solids drawing the art of `n` with effect `id` builds. +fn solids_built(n: &Node, id: &str) -> usize { + let start = SOLIDS_BUILT.with(std::cell::Cell::get); + assert!(art(n, id).is_some(), "{id}"); + SOLIDS_BUILT.with(std::cell::Cell::get) - start +} + +/// Drawing a 3D object with its shadow on builds each of its solids once: the shadow is cast from +/// the faces the solids drawn kept, so it builds no more solids than the art without a shadow. +#[test] +fn a_shadowed_3d_object_builds_each_solid_once() { + for id in CASTING { + for params in shadow_settings(id) { + for (lit, mut plain) in shadowed(id, ¶ms).into_iter().zip(shadowed(id, ¶ms)) { + plain.appearance.effects.iter_mut().for_each(|e| e.params["shadow"] = json!(false)); + let mut cast = 0; + art(&lit, id).unwrap().walk(&mut |c| cast += usize::from(shadow_opacity(c).is_some())); + assert!(cast > 0, "{id} {params}"); + let built = solids_built(&plain, id); + assert!(built > 0, "{id}"); + assert_eq!(solids_built(&lit, id), built, "{id} {params}: the shadow builds no solid of its own"); + } + } + } +} + +/// The shadow of the faces Extrude & Bevel, Inflate and Rotate kept when they drew their solids, +/// to the bit: the shadow [`cast_shadow`] casts from solids built for the shadow alone. +#[test] +fn the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it() { + for id in [EXTRUDE, INFLATE, ROTATE] { + for params in shadow_settings(id) { + for n in shadowed(id, ¶ms) { + let (_, fx) = solid(&n.appearance.effects).unwrap(); + let shapes = if is_container(&n) { + members(&n, "").unwrap() + } else { + let (path, rule) = profile(&n).unwrap(); + vec![Profile { path, rule, items: solid_paint_sources(&n).1, opacity: 1.0, scopes: vec![] }] + }; + let (drawn, _) = art_for(None, &n, fx, &shapes).unwrap(); + let p = crate::merged_params(&fx.id, &fx.params); + let (caster, settings, cast) = cast_shadow(&fx.id, &p, &shapes).unwrap().unwrap(); + let alone = shadow::scoped_art(n.id, &caster, settings, cast, has_masks(&shapes)); + assert!(!alone.is_empty(), "{id} {params}"); + assert_eq!(format!("{drawn:?}"), format!("{alone:?}"), "{id} {params}"); + } + } + } +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 309aa77409..5c1d11c3ff 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -21,7 +21,10 @@ mod caps; mod mesh; pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; mod shadow; -pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW}; +pub use shadow::{ + MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, SolidFaces, TOO_COMPLEX_SHADOW, extrude_shadowed, inflate_shadowed, + plane_shadowed, revolve_facets_shadowed, revolve_shadowed, +}; use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2}; @@ -948,21 +951,136 @@ fn revolve_face_limit(o: Revolve) -> usize { /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, /// each turned out of the region `rule` fills. -fn revolve_faces( +fn revolve_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Revolve, mesh: Option<&mut Vec>) -> Result, String> { + let draw = mesh.is_none(); + revolve_sweep(path, rule, bounds, options, draw, mesh).map(|(faces, _)| faces) +} + +#[cfg(test)] +thread_local! { + /// How many Revolve surfaces this thread has swept, for the tests. + pub(crate) static REVOLVED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// One contour's profile as a sweep takes it: its points, the tangents at the ends of each edge, +/// and whether each edge walls a hole. +#[derive(Default)] +struct Swept { + points: Vec, + tangents: Vec<[Vec2; 2]>, + holes: Vec, +} + +/// Where a sweep places a profile: about the axis at `axis` and the middle height `center_y`, +/// turned through `sweep` radians in `steps` steps, with the sanitized options `o`. +struct Sweeping { + o: Revolve, + axis: f64, + center_y: f64, + sweep: f64, + steps: usize, +} + +impl Sweeping { + /// Profile point `p` turned to the end of step `step`, in view space. + fn vertex(&self, p: Point, step: usize) -> V3 { + let (s, c) = (self.sweep * step as f64 / self.steps as f64).sin_cos(); + self.o.rotate(V3((p.x - self.axis) * c, p.y - self.center_y, (p.x - self.axis) * s)) + } +} + +/// The end `index` (0 the first, 1 the last) of `profile` and the way its cap faces along the axis: +/// the sign of the end's y less its neighbor's, or with the two level, -1 for an end above the +/// middle height `center_y` and 1 otherwise. +fn cap_outward(profile: &[Point], index: usize, center_y: f64) -> Option<(Point, f64)> { + let neighbours = if index == 0 { profile.first().zip(profile.get(1)) } else { profile.last().zip(profile.get(profile.len().saturating_sub(2))) }; + neighbours.map(|(end, neighbour)| { + let dy = end.y - neighbour.y; + let outward = if dy.abs() > 1e-10 { + dy.signum() + } else if end.y < center_y { + -1.0 + } else { + 1.0 + }; + (*end, outward) + }) +} + +/// Keep for a mesh the faces of one contour's profile `s`, which runs round the region it sweeps as +/// `turn` says (its holes' walls turned the other way): a quad for each edge at each step, then for +/// each end in `caps` (0 the first, 1 the last) the disk, or for a sweep of less than a full turn +/// the sector with its center on the axis. `keep` keeps one face with its outward normal; its +/// error, or an edge with invalid coordinates, stops the faces. +fn keep_profile(at: &Sweeping, s: &Swept, turn: f64, caps: &[usize], mut keep: impl FnMut(Vec, V3) -> Result<(), String>) -> Result<(), String> { + let (o, steps) = (at.o, at.steps); + for (pair, hole) in s.points.windows(2).zip(&s.holes) { + let [a, b] = pair else { continue }; + let turn = if *hole { -turn } else { turn }; + if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + return Err("Revolve profile contains invalid coordinates".into()); + } + for j in 0..steps { + let [v0, v1, v2, v3] = [at.vertex(*a, j), at.vertex(*b, j), at.vertex(*b, j + 1), at.vertex(*a, j + 1)]; + // The edge's outward normal in the profile's plane (distance from the axis, height), + // swept to the middle of the step. + let (dr, dy) = ((b.x - a.x) * turn, (b.y - a.y) * turn); + let (sin, cos) = (at.sweep * (j as f64 + 0.5) / steps as f64).sin_cos(); + keep(vec![v0, v1, v2, v3], o.rotate(V3(dy * cos, -dr, dy * sin)))?; + } + } + for &index in caps { + let Some((end, outward)) = cap_outward(&s.points, index, at.center_y) else { continue }; + let mut cap: Vec = (0..=steps).map(|j| at.vertex(end, j)).collect(); + if o.angle >= 360.0 { + cap.pop(); + } else { + cap.insert(0, o.rotate(V3(0.0, end.y - at.center_y, 0.0))); + } + keep(cap, o.rotate(V3(0.0, outward, 0.0)))?; + } + Ok(()) +} + +/// Keep no more faces for a mesh after error `e`, which is the result when no face is drawn. +fn lose_mesh(draw: bool, mesh: &mut Option<&mut Vec>, lost: &mut Option, e: String) -> Result<(), String> { + if !draw { + return Err(e); + } + *mesh = None; + lost.get_or_insert(e); + Ok(()) +} + +/// [`revolve`]'s faces when `draw`, and with `mesh` the surface's faces before projection kept +/// there from the same sweep, each turned out of the region `rule` fills. While faces are drawn, an +/// error keeping faces for the mesh stops only the mesh: the error comes back beside the faces +/// drawn, and `mesh` keeps no more faces. Without `draw` that error is the result. +fn revolve_sweep( path: &PathData, rule: FillRule, bounds: Rect, options: Revolve, + draw: bool, mut mesh: Option<&mut Vec>, -) -> Result, String> { +) -> Result<(Vec, Option), String> { + #[cfg(test)] + REVOLVED.with(|n| n.set(n.get() + 1)); let o = options.sanitized(); let curve_samples = match o.curve_samples { Some(samples) => samples.clamp(1, CURVE_SAMPLES), None => revolve_curve_samples([path], o)?, }; + let mut lost = None; // For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so // each part's normal turns toward a hole only where it walls one. - let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None }; + let mut search = None; + if mesh.is_some() { + match HoleSearch::new(path, rule) { + Ok(s) => search = Some(s), + Err(e) => lose_mesh(draw, &mut mesh, &mut lost, e)?, + } + } let face_limit = revolve_face_limit(o); // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { @@ -1000,10 +1118,8 @@ fn revolve_faces( // Only the longest contour of a compound profile carries the graphics. let surface_contour = o.map_surface.then(|| longest_contour(path, axis)).flatten(); let mut faces = Vec::new(); - let vertex = |p: Point, step: usize| { - let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); - o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s)) - }; + let at = Sweeping { o, axis, center_y, sweep, steps }; + let vertex = |p: Point, step: usize| at.vertex(p, step); let normal = |tangent: Vec2, step: usize| { let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); o.rotate(V3(tangent.y * c, -tangent.x, tangent.y * s).unit()) @@ -1019,17 +1135,26 @@ fn revolve_faces( } Ok(()) }; - let mut count = 0; + // The points of the profile drawn, and of the profile kept for the mesh, which takes the parts + // its edges are cut into. + let (mut count, mut kept_count) = (0, 0); let mut curve_budget = 0; for (contour, sp) in path.subpaths.iter().enumerate() { - let mut profile = Vec::new(); - let mut tangents = Vec::new(); - let mut hole_edges = Vec::new(); + let (mut drawn, mut kept) = (Swept::default(), Swept::default()); if let Some(first) = sp.anchors.first() { - profile.push(first.p); - count += 1; - if count > MAX_PROFILE_POINTS { - return Err(TOO_COMPLEX_PROFILE.into()); + if draw { + drawn.points.push(first.p); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } + } + if mesh.is_some() { + kept.points.push(first.p); + kept_count += 1; + if kept_count > MAX_PROFILE_POINTS { + lose_mesh(draw, &mut mesh, &mut lost, TOO_COMPLEX_PROFILE.into())?; + } } } let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; @@ -1061,22 +1186,40 @@ fn revolve_faces( let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2(); if v.hypot2() > 1e-20 { v } else { b - a } }; - let a = profile.last().copied().unwrap_or(b); - let parts = match search.as_mut() { - Some(search) => search.parts(contour, a, b)?, - None => vec![(b, false)], - }; - for (end, hole) in parts { - tangents.push([tangent(t0), tangent(t1)]); - hole_edges.push(hole); - profile.push(end); + let ends = [tangent(t0), tangent(t1)]; + if draw { + drawn.tangents.push(ends); + drawn.holes.push(false); + drawn.points.push(b); count += 1; if count > MAX_PROFILE_POINTS { return Err(TOO_COMPLEX_PROFILE.into()); } } + let Some(search) = search.as_mut().filter(|_| mesh.is_some()) else { continue }; + let a = kept.points.last().copied().unwrap_or(b); + let parts = match search.parts(contour, a, b) { + Ok(parts) => parts, + Err(e) => { + lose_mesh(draw, &mut mesh, &mut lost, e)?; + continue; + } + }; + for (end, hole) in parts { + kept.tangents.push(ends); + kept.holes.push(hole); + kept.points.push(end); + kept_count += 1; + if kept_count > MAX_PROFILE_POINTS { + lose_mesh(draw, &mut mesh, &mut lost, TOO_COMPLEX_PROFILE.into())?; + break; + } + } } } + // The two profiles start and end at the same points: the parts of the last edge end where + // it does. + let profile = if draw { &drawn.points } else { &kept.points }; let ends = o.cap_ends && !sp.closed && profile.len() >= 2 && profile.first().zip(profile.last()).is_some_and(|(a, b)| a.distance(*b) > 1e-10); let mut rings = [None, None]; if ends { @@ -1088,33 +1231,49 @@ fn revolve_faces( } } } - // For a mesh: which way the profile runs round the region it sweeps (closed against the - // axis at its ends when it is open), so the faces' normals point out of the solid. - let turn = { - let mut ring: Vec<(f64, f64)> = profile.iter().map(|p| (p.x - axis, p.y)).collect(); - if let (false, Some(first), Some(last)) = (sp.closed, profile.first(), profile.last()) { - ring.extend([(0.0, last.y), (0.0, first.y)]); + // Which way a profile runs round the region it sweeps (closed against the axis at its ends + // when it is open), so the faces' normals point out of the solid. A closed contour that does + // not touch the axis has its side cut opposite the middle of its outer side; an anchor goes + // there, so no face runs across the cut. That one anchor is not counted against the limit + // on the profile's points. + let prepare = |s: &mut Swept| { + let turn = { + let mut ring: Vec<(f64, f64)> = s.points.iter().map(|p| (p.x - axis, p.y)).collect(); + if let (false, Some(first), Some(last)) = (sp.closed, s.points.first(), s.points.last()) { + ring.extend([(0.0, last.y), (0.0, first.y)]); + } + let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); + if area < 0.0 { -1.0 } else { 1.0 } + }; + if sp.closed + && surface_contour == Some(contour) + && let Some((k, t)) = ring_cut(&s.points, axis) + && let (Some(a), Some(b), Some([ta, tb]), Some(hole)) = + (s.points.get(k), s.points.get(k + 1), s.tangents.get(k).copied(), s.holes.get(k).copied()) + { + let point = *a + (*b - *a) * t; + let middle = ta + (tb - ta) * t; + s.points.insert(k + 1, point); + s.tangents.splice(k..=k, [[ta, middle], [middle, tb]]); + s.holes.insert(k + 1, hole); } - let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); - if area < 0.0 { -1.0 } else { 1.0 } + turn }; - // A closed contour that does not touch the axis has its side cut opposite the middle of its - // outer side; an anchor goes there, so no face runs across the cut. That one anchor is not - // counted against the limit on the profile's points. - if sp.closed - && surface_contour == Some(contour) - && let Some((k, t)) = ring_cut(&profile, axis) - && let (Some(a), Some(b), Some([ta, tb]), Some(hole)) = - (profile.get(k), profile.get(k + 1), tangents.get(k).copied(), hole_edges.get(k).copied()) - { - let point = *a + (*b - *a) * t; - let middle = ta + (tb - ta) * t; - profile.insert(k + 1, point); - tangents.splice(k..=k, [[ta, middle], [middle, tb]]); - hole_edges.insert(k + 1, hole); + if let Some(m) = mesh.as_deref_mut() { + let turn = prepare(&mut kept); + let caps: Vec = rings.iter().enumerate().filter_map(|(i, r)| r.as_ref().map(|_| i)).collect(); + let kept = keep_profile(&at, &kept, turn, &caps, |face, normal| keep(m, face, normal)); + if let Err(e) = kept { + lose_mesh(draw, &mut mesh, &mut lost, e)?; + } } - let along = along_profile(&profile, axis, sp.closed); - for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() { + if !draw { + continue; + } + let turn = prepare(&mut drawn); + let profile = &drawn.points; + let along = along_profile(profile, axis, sp.closed); + for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(drawn.tangents.iter().copied()).zip(drawn.holes.iter().copied()).enumerate() { let [a, b] = pair else { continue }; let turn = if hole { -turn } else { turn }; let [va, vb] = along.get(i).copied().unwrap_or_default(); @@ -1123,14 +1282,6 @@ fn revolve_faces( } for j in 0..steps { let [v0, v1, v2, v3] = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)]; - if let Some(mesh) = mesh.as_deref_mut() { - // The edge's outward normal in the profile's plane (distance from the axis, - // height), swept to the middle of the step. - let (dr, dy) = ((b.x - a.x) * turn, (b.y - a.y) * turn); - let (s, c) = (sweep * (j as f64 + 0.5) / steps as f64).sin_cos(); - keep(mesh, vec![v0, v1, v2, v3], o.rotate(V3(dy * c, -dr, dy * s)))?; - continue; - } let first = (i == 0).then(|| rings.first().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); let last = (i + 2 == profile.len()).then(|| rings.last().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); // Where the corners lie on the unrolled side surface, and whether the face shows @@ -1173,28 +1324,7 @@ fn revolve_faces( } } for (index, ring) in rings.iter().enumerate().filter_map(|(i, r)| r.as_ref().map(|r| (i, r))) { - let neighbours = - if index == 0 { profile.first().zip(profile.get(1)) } else { profile.last().zip(profile.get(profile.len().saturating_sub(2))) }; - if let Some((end, neighbour)) = neighbours { - let dy = end.y - neighbour.y; - let outward = if dy.abs() > 1e-10 { - dy.signum() - } else if end.y < center_y { - -1.0 - } else { - 1.0 - }; - if let Some(mesh) = mesh.as_deref_mut() { - // The disk, or for a partial sweep the sector with its centre on the axis. - let mut cap: Vec = (0..=steps).map(|j| vertex(*end, j)).collect(); - if o.angle >= 360.0 { - cap.pop(); - } else { - cap.insert(0, o.rotate(V3(0.0, end.y - center_y, 0.0))); - } - keep(mesh, cap, o.rotate(V3(0.0, outward, 0.0)))?; - continue; - } + if let Some((_, outward)) = cap_outward(profile, index, center_y) { // Keep the original tessellation work budget, even though each cap // now produces only one curved vector face. emit(ring.face(bounds, o, light, outward)?, steps)?; @@ -1202,7 +1332,7 @@ fn revolve_faces( } } faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - Ok(faces) + Ok((faces, lost)) } #[cfg(test)] diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index b12bd315f5..b3d10c4166 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::{FillRule, PathData, Rect}; -use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; +use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; use crate::{Extrude, Inflate, Revolve, V3, View}; /// Most steps the triangulation of one solid may take: a face's ears and bridges each test the @@ -56,12 +56,12 @@ impl Mesh { /// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh. pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { - extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces)) + extrude_faces(path, rule, bounds, options, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?)) } /// Rotate's plane ([`crate::plane`]) as a mesh. pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result { - plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces)) + plane_faces(path, rule, bounds, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?)) } /// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one @@ -72,7 +72,7 @@ pub fn inflate_meshes( options: Inflate, view: View, ) -> impl Iterator> + use<> { - inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces))) + inflate_faces(shapes, bounds, options, view, Keep::Faces).map(|r| r.and_then(|(_, faces, _)| mesh(&faces?))) } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs index d7cd01a869..1c2fc661da 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -11,8 +11,8 @@ use std::ops::Range; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; -use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; -use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale}; +use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; +use crate::{Extrude, Face, Facet, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale}; /// Where the shadow falls. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -160,14 +160,14 @@ impl ShadowCaster { /// Add Extrude & Bevel's solid ([`crate::extrude`]). pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?; - self.add(faces, projection) + let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, Keep::Faces)?; + self.add(faces?, projection) } /// Add Rotate's plane ([`crate::plane`]). pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?; - self.add(faces, projection) + let (_, faces, projection) = plane_faces(path, rule, bounds, view, Keep::Faces)?; + self.add(faces?, projection) } /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each @@ -182,11 +182,11 @@ impl ShadowCaster { options: Inflate, view: View, ) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, true) + inflate_faces(shapes, bounds, options, view, Keep::Faces) .enumerate() .map(|(i, r)| { let (_, faces, projection) = r?; - if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) } + if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) } }) .collect() } @@ -199,6 +199,14 @@ impl ShadowCaster { self.add(faces, Some(crate::revolve_projection(bounds, o))) } + /// Add the faces of a solid kept by the build that drew it ([`extrude_shadowed`], + /// [`plane_shadowed`], [`inflate_shadowed`], [`revolve_shadowed`]): the faces + /// [`ShadowCaster::extrude`], [`ShadowCaster::plane`], [`ShadowCaster::inflate`] or + /// [`ShadowCaster::revolve`] adds for that solid, or the error it gives. + pub fn add_solid(&mut self, solid: SolidFaces) -> Result, String> { + self.add(solid.faces?, solid.projection) + } + /// The shadow of the faces gathered, on the page, filled with the nonzero rule: `None` when /// there are none, when the first light lights nothing (Shading is off or its intensity is 0) /// or does not reach the plane (it is behind the object for a shadow behind it, or below the @@ -287,6 +295,56 @@ impl ShadowCaster { } } +/// A solid's faces before projection, kept by the build that drew it, for its shadow +/// ([`ShadowCaster::add_solid`]): the faces, or the error keeping them gave, and the projection the +/// solid was drawn with. +#[derive(Clone, Debug)] +pub struct SolidFaces { + faces: Result, String>, + projection: Option, +} + +/// Extrude & Bevel's solid ([`crate::extrude`]) and, from the same build, the faces +/// [`ShadowCaster::extrude`] adds for it. An error where [`crate::extrude`] gives one. +pub fn extrude_shadowed(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<(Vec, SolidFaces), String> { + let (facets, faces, projection) = extrude_faces(path, rule, bounds, options, view, Keep::Both)?; + Ok((facets, SolidFaces { faces, projection })) +} + +/// Rotate's plane ([`crate::plane`]) and, from the same build, the face [`ShadowCaster::plane`] +/// adds for it. An error where [`crate::plane`] gives one. +pub fn plane_shadowed(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<(Vec, SolidFaces), String> { + let (facets, faces, projection) = plane_faces(path, rule, bounds, view, Keep::Both)?; + Ok((facets, SolidFaces { faces, projection })) +} + +/// Inflate's solids for several shapes of one object ([`crate::inflate_each`]) and, from the same +/// builds, the faces [`ShadowCaster::inflate`] adds for each, each solid built as the iterator +/// reaches it. An error where [`crate::inflate_each`] gives one. +pub fn inflate_shadowed( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator, SolidFaces), String>> + use<> { + inflate_faces(shapes, bounds, options, view, Keep::Both).map(|r| r.map(|(facets, faces, projection)| (facets, SolidFaces { faces, projection }))) +} + +/// Revolve's surface ([`crate::revolve`]) and, from the same sweep, the faces +/// [`ShadowCaster::revolve`] adds for it with the nonzero fill rule. An error where +/// [`crate::revolve`] gives one. +pub fn revolve_shadowed(path: &PathData, bounds: Rect, options: Revolve) -> Result<(Vec, SolidFaces), String> { + let mut kept = vec![]; + let (faces, lost) = crate::revolve_sweep(path, FillRule::NonZero, bounds, options, true, Some(&mut kept))?; + let projection = Some(crate::revolve_projection(bounds, options.sanitized())); + Ok((faces, SolidFaces { faces: lost.map_or(Ok(kept), Err), projection })) +} + +/// [`revolve_shadowed`] with the faces as facets, as [`crate::revolve_facets`] gives them. +pub fn revolve_facets_shadowed(path: &PathData, bounds: Rect, options: Revolve) -> Result<(Vec, SolidFaces), String> { + revolve_shadowed(path, bounds, options).map(|(faces, kept)| (faces.into_iter().map(Facet::of_face).collect(), kept)) +} + /// Whether `area`, the signed area of a polygon with corners `points`, is within rounding of /// nothing for its size (the square of its bounds' diagonal), or not a number, so the polygon draws /// nothing. A long, narrow polygon keeps an area well above that. diff --git a/crates/three-d/src/shadow/tests.rs b/crates/three-d/src/shadow/tests.rs index 3fb3767177..b6401b0626 100644 --- a/crates/three-d/src/shadow/tests.rs +++ b/crates/three-d/src/shadow/tests.rs @@ -185,3 +185,244 @@ 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:?}"); } + +/// The faces `caster` gathered and the projection it keeps, every number to the bit. +fn gathered(caster: &ShadowCaster) -> String { + format!("{:?} {:?}", caster.faces, caster.projection) +} + +/// What a solid kept for its shadow casts ([`casts`]). +fn cast_from(solid: SolidFaces) -> Result<(String, Option, Option), String> { + let mut caster = ShadowCaster::new(); + caster.add_solid(solid)?; + Ok(casts(&caster)) +} + +/// The faces `caster` gathered, and the shadows they cast behind and below. +fn casts(caster: &ShadowCaster) -> (String, Option, Option) { + let below = Shadow { position: ShadowPosition::Below, distance: 0.3, ..Shadow::default() }; + (gathered(caster), caster.path(Shadow::default()), caster.path(below)) +} + +/// A star of `points` points about (100, 100), its edges curved, with a square hole. +fn star(points: usize) -> PathData { + let mut b = BezPath::new(); + for k in 0..2 * points { + let (s, c) = (k as f64 * std::f64::consts::PI / points as f64).sin_cos(); + let r = if k % 2 == 0 { 90.0 } else { 45.0 }; + let p = (100.0 + r * c, 100.0 + r * s); + if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 3.0, p.1), (p.0, p.1 + 3.0), p) } + } + b.close_path(); + rect(&mut b, 90.0, 90.0, 20.0, 20.0); + PathData::from_bezpath(&b) +} + +/// A closed rectangle from (`x`, `y`), `w` wide and `h` high. +fn rect(b: &mut BezPath, x: f64, y: f64, w: f64, h: f64) { + b.move_to((x, y)); + b.line_to((x + w, y)); + b.line_to((x + w, y + h)); + b.line_to((x, y + h)); + b.close_path(); +} + +/// The views the solids are compared in: straight on, turned, with perspective, and with colored +/// lights. +fn views() -> Vec { + let turned = View { rotation_x: -25.0, rotation_y: 35.0, rotation_z: 10.0, ..View::default() }; + vec![front(), turned, View { perspective: 60.0, ..turned }, View { light_color: [1.0, 0.5, 0.25], ..turned }] +} + +/// Extrude & Bevel built once for its facets and its shadow gives the facets [`crate::extrude`] +/// gives and casts what [`ShadowCaster::extrude`] casts, to the bit, errors included. +#[test] +fn an_extruded_solid_and_its_shadow_match_the_two_built_apart() { + use crate::{Bevel, BevelShape}; + let bevel = Bevel { shape: BevelShape::Rounded, width: 6.0, height: 5.0, ..Bevel::default() }; + let options = [ + Extrude::default(), + Extrude { depth: 0.0, ..Extrude::default() }, + Extrude { cap: false, ..Extrude::default() }, + Extrude { hidden: true, ..Extrude::default() }, + Extrude { twist: 90.0, taper: 0.6, ..Extrude::default() }, + Extrude { bevel: Some(bevel), ..Extrude::default() }, + Extrude { bevel: Some(Bevel { both_sides: true, inside: true, ..bevel }), twist: 45.0, ..Extrude::default() }, + ]; + let path = star(7); + let r = path.bounds().unwrap(); + for view in views() { + for o in options { + let (facets, faces) = extrude_shadowed(&path, FillRule::NonZero, r, o, view).unwrap(); + let apart = crate::extrude(&path, FillRule::NonZero, r, o, view).unwrap(); + assert_eq!(format!("{facets:?}"), format!("{apart:?}"), "{o:?} {view:?}"); + let mut caster = ShadowCaster::new(); + caster.extrude(&path, FillRule::NonZero, r, o, view).unwrap(); + assert!(!caster.is_empty()); + assert_eq!(cast_from(faces), Ok(casts(&caster)), "{o:?} {view:?}"); + } + } + let flat = Rect::new(0.0, 0.0, f64::INFINITY, 10.0); + let error = crate::extrude(&path, FillRule::NonZero, flat, Extrude::default(), front()).err(); + assert!(error.is_some()); + assert_eq!(extrude_shadowed(&path, FillRule::NonZero, flat, Extrude::default(), front()).err(), error); +} + +/// Rotate's plane built once for its facet and its shadow matches the two built apart. +#[test] +fn a_rotated_plane_and_its_shadow_match_the_two_built_apart() { + let path = star(5); + let r = path.bounds().unwrap(); + for view in views() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let (facets, faces) = plane_shadowed(&path, rule, r, view).unwrap(); + assert_eq!(format!("{facets:?}"), format!("{:?}", crate::plane(&path, rule, r, view).unwrap()), "{view:?}"); + let mut caster = ShadowCaster::new(); + caster.plane(&path, rule, r, view).unwrap(); + assert_eq!(cast_from(faces), Ok(casts(&caster)), "{view:?}"); + } + } +} + +/// Inflate's solids built once for their facets and shadows match the two built apart: several +/// puffs under one eye, both sides, open backs, and twisted sides cut finer than room is left for. +#[test] +fn inflated_solids_and_their_shadows_match_the_two_built_apart() { + let star = star(5); + let box_ = square(200.0, 20.0, 60.0); + let ring: Vec = (0..600) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 600.0).sin_cos(); + Point::new(100.0 + 90.0 * c, 100.0 + 90.0 * s) + }) + .collect(); + let ring = PathData::single(vectorcraft_geom::SubPath::polyline(&ring, true)); + let sets = [vec![(&star, FillRule::NonZero), (&box_, FillRule::NonZero), (&star, FillRule::EvenOdd)], vec![(&ring, FillRule::NonZero)]]; + let bounds = Rect::new(0.0, 0.0, 280.0, 200.0); + let options = [ + Inflate::default(), + Inflate { depth: 30.0, both_sides: true, ..Inflate::default() }, + Inflate { depth: 20.0, cap: false, volume: 80.0, ..Inflate::default() }, + Inflate { depth: 40.0, twist: 360.0, taper: 0.7, both_sides: true, ..Inflate::default() }, + // Thin and barely twisted: the star's sides want more pieces than room is left after its + // front and back, and the shadow, which keeps the faces turned away, has less room left. + Inflate { depth: 0.1, twist: 0.1, both_sides: true, ..Inflate::default() }, + ]; + for shapes in &sets { + for view in views() { + for o in options { + let built: Vec<_> = inflate_shadowed(shapes, bounds, o, view).collect(); + let facets: Vec<_> = crate::inflate_each(shapes, bounds, o, view).collect(); + let mut caster = ShadowCaster::new(); + let added = caster.inflate(shapes, &vec![true; shapes.len()], bounds, o, view); + assert!(added.iter().all(Result::is_ok)); + assert_eq!(built.len(), shapes.len()); + let mut one = ShadowCaster::new(); + for (b, f) in built.into_iter().zip(&facets) { + let ((facets, faces), f) = (b.unwrap(), f.as_ref().unwrap()); + assert_eq!(format!("{facets:?}"), format!("{f:?}"), "{o:?} {view:?}"); + one.add_solid(faces).unwrap(); + } + assert_eq!(casts(&one), casts(&caster), "{o:?} {view:?}"); + } + } + } +} + +/// Revolve's surface swept once for its faces and its shadow gives the faces [`crate::revolve`] +/// gives and casts what [`ShadowCaster::revolve`] casts, to the bit: an open profile with caps, +/// closed contours that cross (their walls cut where they do), a hole, partial sweeps, smooth and +/// flat shading, and the cut side that graphics take. +#[test] +fn a_revolved_surface_and_its_shadow_match_the_two_swept_apart() { + let mut open = BezPath::new(); + open.move_to((120.0, 0.0)); + open.curve_to((160.0, 20.0), (100.0, 60.0), (140.0, 90.0)); + open.line_to((125.0, 120.0)); + let mut crossing = BezPath::new(); + rect(&mut crossing, 110.0, 0.0, 60.0, 80.0); + rect(&mut crossing, 150.0, 40.0, 40.0, 80.0); + let mut holed = BezPath::new(); + holed.move_to((110.0, 0.0)); + holed.curve_to((200.0, 0.0), (200.0, 120.0), (110.0, 120.0)); + holed.close_path(); + rect(&mut holed, 130.0, 40.0, 20.0, 30.0); + let paths = [open, crossing, holed].map(|b| PathData::from_bezpath(&b)); + let turned = Revolve { rotation_x: -20.0, rotation_y: 30.0, ..Revolve::default() }; + let options = [ + Revolve::default(), + turned, + Revolve { angle: 200.0, perspective: 40.0, ..turned }, + Revolve { smooth_shading: false, segments: 12, ..turned }, + Revolve { cap_ends: false, offset: 15.0, ..turned }, + Revolve { map_surface: true, edge: crate::Edge::Right, ..turned }, + Revolve { map_surface: true, angle: 300.0, curve_samples: Some(5), ..turned }, + ]; + for path in &paths { + let bounds = path.bounds().unwrap(); + for o in options { + let (faces, kept) = revolve_shadowed(path, bounds, o).unwrap(); + assert_eq!(format!("{faces:?}"), format!("{:?}", crate::revolve(path, bounds, o).unwrap()), "{o:?}"); + let (facets, also) = revolve_facets_shadowed(path, bounds, o).unwrap(); + assert_eq!(format!("{facets:?}"), format!("{:?}", crate::revolve_facets(path, bounds, o).unwrap()), "{o:?}"); + assert_eq!(cast_from(also), cast_from(kept.clone()), "{o:?}"); + let mut caster = ShadowCaster::new(); + caster.revolve(path, FillRule::NonZero, bounds, o).unwrap(); + assert!(!caster.is_empty()); + assert_eq!(cast_from(kept), Ok(casts(&caster)), "{o:?}"); + } + } +} + +/// A surface whose shadow is past the limit on the profile's points (its walls cut where they +/// cross another contour) still draws every face, and its shadow gives the error the shadow swept +/// alone gives. +#[test] +fn a_surface_whose_shadow_is_too_complex_still_draws() { + let mut b = BezPath::new(); + b.move_to((110.0, 0.0)); + for i in 1..6000 { + b.line_to((110.0 + f64::from(i) * 0.02, if i % 2 == 0 { 0.0 } else { 10.0 })); + } + b.line_to((110.0, 20.0)); + b.close_path(); + rect(&mut b, 100.0, 4.0, 140.0, 2.0); + let path = PathData::from_bezpath(&b); + let bounds = path.bounds().unwrap(); + let o = Revolve { segments: 4, smooth_shading: false, ..Revolve::default() }; + let (faces, kept) = revolve_shadowed(&path, bounds, o).unwrap(); + let drawn = crate::revolve(&path, bounds, o).unwrap(); + assert!(!drawn.is_empty()); + assert!(faces == drawn); + let alone = ShadowCaster::new().revolve(&path, FillRule::NonZero, bounds, o).unwrap_err(); + assert!(alone.contains("too complex"), "{alone}"); + assert_eq!(cast_from(kept).unwrap_err(), alone); +} + +/// One build gives the facets and the faces of the shadow: each solid is built once. +#[test] +fn a_solid_and_its_shadow_are_built_once() { + let path = star(5); + let r = path.bounds().unwrap(); + let count = |n: &'static std::thread::LocalKey>| n.with(std::cell::Cell::get); + let start = count(&crate::solid::EXTRUDED); + let (facets, faces) = extrude_shadowed(&path, FillRule::NonZero, r, Extrude::default(), front()).unwrap(); + assert!(!facets.is_empty() && cast_from(faces).is_ok_and(|c| c.1.is_some())); + assert_eq!(count(&crate::solid::EXTRUDED), start + 1); + let start = count(&crate::solid::PLANES); + let (facets, faces) = plane_shadowed(&path, FillRule::NonZero, r, front()).unwrap(); + assert!(!facets.is_empty() && cast_from(faces).is_ok_and(|c| c.1.is_some())); + assert_eq!(count(&crate::solid::PLANES), start + 1); + let start = count(&crate::solid::INFLATED); + let box_ = square(200.0, 20.0, 60.0); + let shapes = [(&path, FillRule::NonZero), (&box_, FillRule::NonZero)]; + for solid in inflate_shadowed(&shapes, Rect::new(0.0, 0.0, 280.0, 200.0), Inflate::default(), front()) { + let (facets, faces) = solid.unwrap(); + assert!(!facets.is_empty() && cast_from(faces).is_ok_and(|c| c.1.is_some())); + } + assert_eq!(count(&crate::solid::INFLATED), start + 2); + let start = count(&crate::REVOLVED); + let (drawn, faces) = revolve_shadowed(&path, r, Revolve::default()).unwrap(); + assert!(!drawn.is_empty() && cast_from(faces).is_ok_and(|c| c.1.is_some())); + assert_eq!(count(&crate::REVOLVED), start + 1); +} diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 49b0738d9e..792e25c8b1 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -20,9 +20,13 @@ mod membrane; mod outline; mod rings; +#[cfg(test)] +pub(crate) use build::EXTRUDED; +#[cfg(test)] +pub(crate) use build::PLANES; #[cfg(test)] use build::{Camera, cuts, edge_distance, newell, section}; -pub(crate) use build::{Face3, extrude_faces, plane_faces}; +pub(crate) use build::{Face3, Keep, extrude_faces, plane_faces}; pub use build::{extrude, plane}; pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 6d21805e74..fe73b9bd3f 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -64,9 +64,25 @@ impl Camera { /// and its outward normal. pub(crate) type Face3 = (Vec>, V3); -/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection, -/// and the projection it used (`None` when nothing was built). -pub(crate) type Built = (Vec, Vec, Option); +/// What a solid's builder makes: its facets, its faces before projection when a mesh is asked for +/// (an error when they pass [`MAX_SOLID_FACES`] while the facets are built too), and the +/// projection it used (`None` when nothing was built). +pub(crate) type Built = (Vec, Result, String>, Option); + +#[cfg(test)] +thread_local! { + /// How many Extrude & Bevel solids this thread has built, for the tests. + pub(crate) static EXTRUDED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// What a solid's builder keeps: its facets, its faces before projection for a mesh, or both +/// from one build. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Keep { + Facets, + Faces, + Both, +} /// Facets being collected for one solid. pub(super) struct Builder<'a> { @@ -80,6 +96,11 @@ pub(super) struct Builder<'a> { pub(super) facets: Vec, /// With `Some`, every face goes here as it is, none hidden or projected, for a mesh. pub(super) mesh: Option>, + /// The faces are projected into `facets`; without it only `mesh` is kept. + pub(super) draw: bool, + /// The faces kept for the mesh passed [`MAX_SOLID_FACES`] while the facets were built too: the + /// mesh is too complex, and `mesh` keeps no more faces. + pub(super) lost: bool, } /// Where a face goes in the order the facets are drawn, back to front. @@ -98,20 +119,46 @@ pub(super) enum Order { Last, } -impl Builder<'_> { +impl<'a> Builder<'a> { + /// A builder for one solid seen by `camera`, keeping what `keep` asks for. + pub(super) fn new(camera: &'a Camera, closed: bool, hidden: bool, keep: Keep) -> Self { + Self { camera, closed, hidden, facets: vec![], mesh: (keep != Keep::Facets).then(Vec::new), draw: keep != Keep::Faces, lost: false } + } + /// Add a face of view-space contours with outward normal `n` (view space), drawn in `order`. pub(super) fn face(&mut self, contours: Vec>, n: V3, order: Order) -> Result<(), String> { let count: usize = contours.iter().map(Vec::len).sum(); if count < 3 { return Ok(()); } - if let Some(mesh) = &mut self.mesh { - mesh.push((contours, n.unit())); - if mesh.len() > MAX_SOLID_FACES { + if self.draw { + self.facet(&contours, count, n, order)?; + } + let Some(mesh) = &mut self.mesh else { return Ok(()) }; + mesh.push((contours, n.unit())); + if mesh.len() > MAX_SOLID_FACES { + if !self.draw { return Err(TOO_COMPLEX_SURFACE.into()); } - return Ok(()); + self.lose_mesh(); } + Ok(()) + } + + /// Keep no more faces for the mesh, which is too complex. + pub(super) fn lose_mesh(&mut self) { + self.mesh = None; + self.lost = true; + } + + /// How many faces the mesh holds, while the facets are built too and the mesh is kept. + pub(super) fn faces_kept(&self) -> Option { + self.mesh.as_ref().filter(|_| self.draw).map(Vec::len) + } + + /// Project a face of `count` view-space corners with outward normal `n` into a facet, drawn in + /// `order`, unless it is hidden or has no area. + fn facet(&mut self, contours: &[Vec], count: usize, n: V3, order: Order) -> Result<(), String> { let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); let n = n.unit(); @@ -138,7 +185,7 @@ impl Builder<'_> { let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye }); #[cfg(test)] - let view = contours.clone(); + let view = contours.to_vec(); let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) { return Err("The 3D projection is not finite".into()); @@ -262,7 +309,7 @@ impl Builder<'_> { /// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the /// outline `outline` makes from its projected contours. pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec]) -> Option) { - if self.mesh.is_some() || self.facets.len() <= before { + if !self.draw || self.facets.len() <= before { return; } if let Some(f) = self.facets.last_mut() { @@ -273,7 +320,7 @@ impl Builder<'_> { /// Mark the facet that [`Builder::face`] added, when it added one after `before` facets, as the /// planar end `end` of the solid. pub(super) fn end(&mut self, before: usize, end: PlanarFace) { - if self.mesh.is_some() || self.facets.len() <= before { + if !self.draw || self.facets.len() <= before { return; } if let Some(f) = self.facets.last_mut() { @@ -281,15 +328,19 @@ impl Builder<'_> { } } - /// How many faces the builder holds: facets, or faces kept for a mesh. + /// How many faces the builder holds: facets, or without them the faces kept for a mesh. pub(super) fn len(&self) -> usize { - self.mesh.as_ref().map_or(self.facets.len(), Vec::len) + match &self.mesh { + Some(mesh) if !self.draw => mesh.len(), + _ => self.facets.len(), + } } /// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]). pub(super) fn finish(mut self) -> Built { self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - (self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection())) + let faces = if self.lost { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(self.mesh.unwrap_or_default()) }; + (self.facets, faces, Some(self.camera.projection())) } } @@ -536,16 +587,18 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets) + extrude_faces(path, rule, bounds, options, view, Keep::Facets).map(|(facets, ..)| facets) } -/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets. -pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result { +/// [`extrude`], keeping the solid's facets, its faces before projection, or both, as `keep` asks. +pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, keep: Keep) -> Result { + #[cfg(test)] + EXTRUDED.with(|n| n.set(n.get() + 1)); check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane_faces(path, rule, bounds, view, mesh); + return plane_faces(path, rule, bounds, view, keep); } let steps = steps(&o); // One face for each edge of the outline in each band between two steps, and the two caps: @@ -561,7 +614,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio return Err(TOO_COMPLEX_SURFACE.into()); } let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, o.cap, o.hidden, keep); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; @@ -644,18 +697,27 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio Ok(b.finish()) } +#[cfg(test)] +thread_local! { + /// How many Rotate planes (and Extrude & Bevel solids with no depth) this thread has built, for + /// the tests. + pub(crate) static PLANES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets) + plane_faces(path, rule, bounds, view, Keep::Facets).map(|(facets, ..)| facets) } -/// [`plane`], or with `mesh` its face before projection instead of its facet. -pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { +/// [`plane`], keeping its facet, its face before projection, or both, as `keep` asks. +pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, keep: Keep) -> Result { + #[cfg(test)] + PLANES.with(|n| n.set(n.get() + 1)); check_bounds(bounds)?; let rings = solid_rings_traced(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, false, false, keep); let contours: Vec> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d3773a92f9..677e9cc8bc 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,4 +1,4 @@ -use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; +use super::build::{Builder, Built, Camera, Keep, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material}; use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; @@ -73,26 +73,26 @@ pub fn inflate_each( options: Inflate, view: View, ) -> impl Iterator, String>> + use<> { - inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)) + inflate_faces(shapes, bounds, options, view, Keep::Facets).map(|r| r.map(|(facets, ..)| facets)) } -/// [`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. +/// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as +/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff); +/// each solid is built as the iterator reaches it. pub(crate) fn inflate_faces( shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, - mesh: bool, + keep: Keep, ) -> impl Iterator> + use<> { let o = options.sanitized(); let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); puffs.into_iter().map(move |p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![], None)), + Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep), + Ok(None) => Ok((vec![], Ok(vec![]), None)), Err(e) => Err(e), }) } @@ -106,9 +106,9 @@ thread_local! { } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the -/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, -/// its faces before projection instead. -fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. Its faces +/// before projection instead, or both, as `keep` asks. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, keep: Keep) -> Result { #[cfg(test)] INFLATED.with(|n| n.set(n.get() + 1)); let Puff { rings, edges, cell, nx, ny, m, height } = p; @@ -149,7 +149,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, o.cap, false, keep); let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); @@ -375,15 +375,40 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let turn = Turn::new(bounds, o.twist, o.taper, t); ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() }; - let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + let cut: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + let mut pieces = cut.clone(); fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { return Err(TOO_COMPLEX_SURFACE.into()); } - for (ring, pieces) in rings.iter().zip(&pieces) { - for k in 0..slices { - b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + let sides = |b: &mut Builder<'_>, pieces: &[Vec]| -> Result<(), String> { + for (ring, pieces) in rings.iter().zip(pieces) { + for k in 0..slices { + b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + } + } + Ok(()) + }; + // A mesh kept with the facets has the sides a mesh alone has: limited, and with a twist + // cut, by the faces the mesh holds, which can be more than the facets. + let mut own_sides = None; + if let Some(kept) = b.faces_kept() { + let mut own = cut; + fit(&mut own, slices, MAX_FACES.saturating_sub(kept)); + if kept.saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { + b.lose_mesh(); + } else if own != pieces { + own_sides = Some((own, b.mesh.take())); + } + } + sides(&mut b, &pieces)?; + if let Some((own, faces)) = own_sides { + let mut m = Builder::new(&camera, o.cap, false, Keep::Faces); + m.mesh = faces; + match sides(&mut m, &own) { + Ok(()) => b.mesh = m.mesh, + Err(_) => b.lose_mesh(), } } } diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index e89a29afea..335f434419 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -225,12 +225,12 @@ fn a_flat_facet_measures_its_depth_on_its_plane() { /// eye, has no plane: its drawing no longer matches the plane's projection there. #[test] fn a_face_reaching_past_the_eye_has_no_plane() { - use super::build::{Builder, Order}; + use super::build::{Builder, Keep, Order}; let (_, b) = shape(&square(0.0, 0.0, 100.0)); let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0); let eye = camera.center_and_eye().1.unwrap(); for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] { - let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None }; + let mut builder = Builder::new(&camera, false, false, Keep::Facets); let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)]; builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap(); assert_eq!(builder.facets.len(), 1, "{far}"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..3ee60a8230 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -580,6 +580,7 @@ since 2026-10-01. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. - 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. +- 3D shadows: with Shadows on, Revolve, Extrude & Bevel, Inflate and Rotate cast the shadow from the faces kept by the build that draws each solid, so drawing an object builds each of its solids once. The art and the shadow are unchanged. Evidence: `crates/three-d/src/shadow/tests.rs` (`a_solid_and_its_shadow_are_built_once` and the tests that compare each solid and its shadow with the two built apart), `solids::tests_shadow::a_shadowed_3d_object_builds_each_solid_once`, `solids::tests_shadow::the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it`, `revolve::tests::the_shadow_cast_from_the_surfaces_drawn_is_the_shadow_of_surfaces_swept_for_it`. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. - 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. From 7e66a990bc85dd57ea1f3c52e45946c6a16d1c35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:33:17 -0800 Subject: [PATCH 21/82] PDF export: a stored gray or RGB image keeps its ICC profile A gray or RGB TIFF that reached PDF export as a TIFF, for example in a native document or from a script, was decoded to 8-bit RGBA and written as DeviceRGB, which dropped the ICC profile of its tag 34675, its 16-bit samples and its gray color space. It is now written in an ICCBased color space holding that profile, as a tagged PNG is: gray stays gray, 16-bit samples stay 16-bit from PDF 1.5 on, and the alpha becomes a soft mask only when some pixel is not opaque. A gray or RGB image that the Compression settings resample or compress with JPEG keeps its source profile too, a PNG included. Pixels that the Output settings convert are written without it, and so is a profile whose color space differs from the samples'. CMYK images are unchanged. --- crates/pdf/src/export/images.rs | 11 +- crates/pdf/src/images.rs | 114 ++++++++++++++++---- crates/pdf/src/tests/compression.rs | 157 +++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 4 files changed, 256 insertions(+), 27 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index d0158d8cd2..a44aa52526 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -318,8 +318,9 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { const MAX_EMBED_PIXELS: u64 = 1 << 27; /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a -/// sample is written at 8 bits ([`png_8_bit`]). +/// other formats (a TIFF) decoded with the ICC profile they carry and their 16-bit samples +/// ([`crate::images::decoded_image`]). Without `sixteen_bit` (a file before PDF 1.5), a PNG or +/// another image of 16 bits a sample is written at 8 bits ([`png_8_bit`]). pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. @@ -336,11 +337,7 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), _ => None, }; - direct.or_else(|| { - let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); - let (w, h) = rgba.dimensions(); - Some(Image::from_rgba8(rgba.into_raw(), w, h)) - }) + direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit)) } #[cfg(test)] diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 4139259583..85cb29f503 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -26,7 +26,8 @@ use vectorcraft_doc::cmyk::jpeg_components; use crate::output::{CmykPixels, Pixels}; use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; -/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel. +/// Raw pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel, of 8 bits a +/// sample or of 16 (big-endian), with the ICC profile the colour samples are in, if any. #[derive(Clone, Hash)] struct Raw { color: Arc>, @@ -34,6 +35,8 @@ struct Raw { width: u32, height: u32, space: ImageColorspace, + bits: BitsPerComponent, + icc: Option>>, } impl CustomImage for Raw { @@ -46,7 +49,7 @@ impl CustomImage for Raw { } fn bits_per_component(&self) -> BitsPerComponent { - BitsPerComponent::Eight + self.bits } fn size(&self) -> (u32, u32) { @@ -54,7 +57,7 @@ impl CustomImage for Raw { } fn icc_profile(&self) -> Option<&[u8]> { - None + self.icc.as_deref().map(Vec::as_slice) } fn color_space(&self) -> ImageColorspace { @@ -62,24 +65,85 @@ impl CustomImage for Raw { } } +/// `icc` when it is a profile of the colour space `space` names: `GRAY` for grey samples, `RGB ` +/// for RGB ones. `None` for other profiles and spaces. +fn icc_for(icc: Option>, space: ImageColorspace) -> Option>> { + let want = match space { + ImageColorspace::Luma => *b"GRAY", + ImageColorspace::Rgb => *b"RGB ", + ImageColorspace::Cmyk | ImageColorspace::DeviceN => return None, + }; + icc.filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)).map(Arc::new) +} + +/// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, +/// which reads tags of any size), another format's as its decoder reads it. `None` without one. +fn source_icc(bytes: &[u8]) -> Option> { + let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; + if reader.format() == Some(ImageFormat::Tiff) { + return vectorcraft_doc::cmyk::tiff_icc_profile(bytes); + } + let mut decoder = reader.into_decoder().ok()?; + decoder.icc_profile().ok().flatten().filter(|icc| !icc.is_empty()) +} + /// A `width` × `height` image of straight (not premultiplied) RGBA pixels, written as RGB (as /// CMYK when `pixels` converts to CMYK), or as grey when every pixel is grey, with an alpha -/// channel only when some pixel isn't opaque. `interpolate` asks viewers to smooth it when -/// enlarged (PDF/A forbids it). `None` when `rgba` doesn't hold that many pixels. -fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>) -> Option { +/// channel only when some pixel isn't opaque. With an RGB `icc` profile, pixels that `pixels` +/// doesn't convert stay RGB and carry it; a grey one tags grey pixels. `interpolate` asks viewers +/// to smooth it when enlarged (PDF/A forbids it). `None` when `rgba` doesn't hold that many pixels. +fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>, icc: Option>) -> Option { let px = rgba.as_chunks::<4>().0; if px.len() as u64 != width as u64 * height as u64 || px.is_empty() { return None; } - let (color, space) = if neutral(rgba) { + let rgb_icc = pixels.is_none() && icc.as_deref().and_then(vectorcraft_doc::cmyk::icc_color_space) == Some(*b"RGB "); + let (color, space) = if neutral(rgba) && !rgb_icc { (px.iter().map(|p| p[0]).collect(), ImageColorspace::Luma) } else if let Some(cmyk) = pixels.and_then(|p| p.cmyk(rgba)) { (cmyk, ImageColorspace::Cmyk) } else { (px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb) }; + let icc = icc_for(icc.filter(|_| pixels.is_none()), space); let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect())); - Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space }, interpolate).ok() + Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space, bits: BitsPerComponent::Eight, icc }, interpolate).ok() +} + +/// Decoded image `img` written losslessly as it is: grey or RGB samples (other kinds as RGB), with +/// an alpha channel only when some pixel isn't opaque, at 16 bits a sample when it has more than 8 +/// and `sixteen_bit` allows it (PDF 1.5 and later), else at 8; tagged with ICC profile `icc` when +/// that is a profile of the samples' colour space (`GRAY` or `RGB `). `None` when it has no pixels. +pub(crate) fn from_decoded(img: DynamicImage, icc: Option>, interpolate: bool, sixteen_bit: bool) -> Option { + let (width, height) = (img.width(), img.height()); + if width == 0 || height == 0 { + return None; + } + let kind = img.color(); + let space = if kind.has_color() { ImageColorspace::Rgb } else { ImageColorspace::Luma }; + let n = if kind.has_color() { 3 } else { 1 }; + let deep = sixteen_bit && kind.bits_per_pixel() > 8 * u16::from(kind.channel_count()); + let (color, alpha, bits) = if deep { + let s = if kind.has_color() { img.into_rgba16().into_raw() } else { img.into_luma_alpha16().into_raw() }; + let px = s.chunks_exact(n + 1); + let color = px.clone().flat_map(|p| p[..n].iter().flat_map(|v| v.to_be_bytes())).collect(); + let alpha = (kind.has_alpha() && px.clone().any(|p| p[n] < u16::MAX)).then(|| px.flat_map(|p| p[n].to_be_bytes()).collect()); + (color, alpha, BitsPerComponent::Sixteen) + } else { + let s = if kind.has_color() { img.into_rgba8().into_raw() } else { img.into_luma_alpha8().into_raw() }; + let px = s.chunks_exact(n + 1); + let color = px.clone().flat_map(|p| p[..n].iter().copied()).collect(); + let alpha = (kind.has_alpha() && px.clone().any(|p| p[n] < u8::MAX)).then(|| px.map(|p| p[n]).collect()); + (color, alpha, BitsPerComponent::Eight) + }; + let raw = Raw { color: Arc::new(color), alpha: alpha.map(Arc::new), width, height, space, bits, icc: icc_for(icc, space) }; + Image::from_custom(raw, interpolate).ok() +} + +/// Image `bytes` decoded within [`limits`] with the ICC profile it carries ([`source_icc`]), as +/// [`from_decoded`] writes it. `None` when it can't be decoded. +pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool) -> Option { + from_decoded(decoded(bytes)?, source_icc(bytes), interpolate, sixteen_bit) } /// Are all of the RGBA pixels `rgba` grey? @@ -90,7 +154,8 @@ fn neutral(rgba: &[u8]) -> bool { /// Colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) with an optional `alpha` /// channel, written losslessly. fn lossless(samples: Vec, alpha: Option>, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option { - Image::from_custom(Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space }, interpolate).ok() + let raw = Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space, bits: BitsPerComponent::Eight, icc: None }; + Image::from_custom(raw, interpolate).ok() } /// [`from_rgba`] of pixels converted by `pixels` (when given). @@ -98,7 +163,7 @@ pub(crate) fn from_rgba_in(mut rgba: Vec, width: u32, height: u32, interpola if let Some(p) = pixels { p.rgb_in_place(&mut rgba); } - from_rgba(&rgba, width, height, interpolate, pixels) + from_rgba(&rgba, width, height, interpolate, pixels, None) } /// A `width` × `height` image of CMYK ink amounts `inks` (four bytes a pixel), converted by @@ -270,6 +335,11 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in return Recoded { image: None, warning: rule.instead }; } let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep }; + // Pixels that keep their colours keep the profile they are in; converted ones are in the + // destination's. + let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" }; + let icc = convert.is_none().then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)); + let rgb_icc = icc.is_some() && kind == Kind::Color; let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8(); if kind == Kind::Mono { // Resampling greys the edges: back to black and white. @@ -284,16 +354,18 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in let (width, height) = rgba.dimensions(); let opaque = rgba.pixels().all(|p| p[3] == 255); let jpeg = jpeg_quality(&rule, jpeg).filter(|_| opaque).and_then(|q| { - // Grey pixels stay grey; colour ones go to CMYK when the output converts to CMYK. - let grey = kind != Kind::Color || neutral(&rgba); + // Grey pixels stay grey, unless they keep an RGB profile; colour ones go to CMYK when the + // output converts to CMYK. + let grey = kind != Kind::Color || (neutral(&rgba) && !rgb_icc); let (samples, space) = match convert.filter(|_| !grey).and_then(|p| p.cmyk(&rgba)) { Some(cmyk) => (cmyk, ImageColorspace::Cmyk), None if grey => (rgba.pixels().map(|p| p[0]).collect(), ImageColorspace::Luma), None => (rgba.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb), }; - to_jpeg(&samples, space, width, height, q, interpolate) + let icc = icc_for(icc.clone(), space); + to_jpeg(&samples, space, width, height, q, interpolate, icc.as_deref().map(Vec::as_slice)) }); - let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert)); + let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert, icc)); Recoded { image, warning: rule.instead } } @@ -360,7 +432,7 @@ pub(crate) fn recode_cmyk( None => (inks, ImageColorspace::Cmyk), }; // JPEG needs opaque pixels. - let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate)); + let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate, None)); let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate)); Recoded { image, warning: rule.instead } } @@ -405,9 +477,9 @@ pub(crate) fn mask_alpha(blob: &ImageBlob, width: u32, height: u32, clip: bool) } /// Opaque colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) as a JPEG at -/// `quality`. CMYK JPEGs hold Adobe's inverted samples, which the PDF writer's Decode array -/// undoes. -fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool) -> Option { +/// `quality`, carrying grey or RGB samples' ICC profile `icc` when given. CMYK JPEGs hold Adobe's +/// inverted samples, which the PDF writer's Decode array undoes. +fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool, icc: Option<&[u8]>) -> Option { let mut out = Vec::new(); match space { ImageColorspace::Cmyk => { @@ -416,7 +488,11 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual } ImageColorspace::Rgb | ImageColorspace::Luma => { let t = if space == ImageColorspace::Rgb { ExtendedColorType::Rgb8 } else { ExtendedColorType::L8 }; - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode(samples, width, height, t).ok()?; + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality); + if let Some(icc) = icc { + image::ImageEncoder::set_icc_profile(&mut encoder, icc.to_vec()).ok()?; + } + encoder.encode(samples, width, height, t).ok()?; } ImageColorspace::DeviceN => return None, } diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index c02f3f5e1c..8adf91879a 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -5,7 +5,7 @@ use std::io::Cursor; -use craft_pdf::syntax::object::{Name, Object}; +use craft_pdf::syntax::object::{Array, Name, Object, Stream}; use image::{DynamicImage, ImageFormat}; use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; @@ -237,3 +237,158 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None); assert!(r.image.is_none(), "kept as it is"); } + +/// A version 2.1 display ICC profile header for colour space `space` (`RGB ` or `GRAY`), `len` +/// bytes in all, every byte after the header different from its neighbors. +fn icc_profile(space: &[u8; 4], len: usize) -> Vec { + let mut icc: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + icc[..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes()); + icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(space); + icc[36..40].copy_from_slice(b"acsp"); + icc +} + +/// `img` encoded as `format` carrying ICC profile `icc`. +fn tagged(img: &DynamicImage, icc: &[u8], format: ImageFormat) -> Vec { + use image::ImageEncoder as _; + let mut out = Vec::new(); + match format { + ImageFormat::Tiff => { + let mut enc = image::codecs::tiff::TiffEncoder::new(Cursor::new(&mut out)); + enc.set_icc_profile(icc.to_vec()).unwrap(); + img.write_with_encoder(enc).unwrap(); + } + _ => { + let mut enc = image::codecs::png::PngEncoder::new(&mut out); + enc.set_icc_profile(icc.to_vec()).unwrap(); + img.write_with_encoder(enc).unwrap(); + } + } + out +} + +/// An image XObject as it is written. +#[derive(Debug, PartialEq)] +struct Written { + /// Its colour space's name: `ICCBased` for an ICC-based one. + space: String, + /// The profile an ICC-based colour space holds. + icc: Option>, + bits: u8, + filter: String, + smask: bool, +} + +/// Each image XObject of `pdf` other than soft masks. +fn image_spaces(pdf: &[u8]) -> Vec { + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); + let images: Vec> = file + .objects() + .into_iter() + .filter_map(|o| match o { + Object::Stream(s) if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => Some(s), + _ => None, + }) + .collect(); + let masks: Vec> = images.iter().filter_map(|s| s.dict().get::>(b"SMask")).map(|m| m.raw_data().to_vec()).collect(); + let mut out = vec![]; + for s in images.iter().filter(|s| !masks.contains(&s.raw_data().to_vec())) { + let d = s.dict(); + let (space, icc) = match (d.get::>(b"ColorSpace"), d.get::>(b"ColorSpace")) { + (Some(n), _) => (n.as_str().to_string(), None), + (_, Some(a)) => { + let mut it = a.flex_iter(); + let name = it.next::>().unwrap().as_str().to_string(); + (name, it.next::>().map(|s| s.decoded().unwrap().into_owned())) + } + _ => (String::new(), None), + }; + let filter = d.get::>(b"Filter").map(|n| n.as_str().to_string()).unwrap_or_default(); + let (bits, smask) = (d.get::(b"BitsPerComponent").unwrap_or(0), d.get::>(b"SMask").is_some()); + out.push(Written { space, icc, bits, filter, smask }); + } + out +} + +/// A gray or RGB TIFF that carries an ICC profile of its colour space is written in an ICC-based +/// colour space holding the same profile, at 16 bits a sample from PDF 1.5 on, with its alpha as +/// a soft mask; and so is a tagged TIFF or PNG that is resampled or compressed with JPEG. Pixels +/// the Output settings convert, and a profile of another colour space, are written without it. +#[test] +fn tagged_tiffs_keep_their_icc_profile() { + let (rgb, gray) = (icc_profile(b"RGB ", 600), icc_profile(b"GRAY", 300)); + let mut clear = colour().to_rgba8(); + clear.put_pixel(0, 0, image::Rgba([10, 20, 30, 0])); + let tiff = |img: &DynamicImage, icc: &[u8]| doc(tagged(img, icc, ImageFormat::Tiff), "image/tiff"); + let export = |d: &Document, settings: Value| { + let mut settings: PdfSettings = serde_json::from_value(settings).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + image_spaces(&r.bytes) + }; + let icc_based = |icc: &[u8], bits: u8, filter: &str, smask: bool| { + vec![Written { space: "ICCBased".into(), icc: Some(icc.to_vec()), bits, filter: filter.into(), smask }] + }; + let flate = "FlateDecode"; + let cases = [ + ("RGB", tiff(&colour(), &rgb), json!({}), icc_based(&rgb, 8, flate, false)), + ("gray", tiff(&colour().grayscale(), &gray), json!({}), icc_based(&gray, 8, flate, false)), + ( + "RGB, 16 bits", + tiff(&DynamicImage::ImageRgb16(colour().to_rgb16()), &rgb), + json!({"compatibility": "1.7"}), + icc_based(&rgb, 16, flate, false), + ), + ( + "RGB, 16 bits, PDF 1.4", + tiff(&DynamicImage::ImageRgb16(colour().to_rgb16()), &rgb), + json!({"compatibility": "1.4"}), + icc_based(&rgb, 8, flate, false), + ), + ("RGBA", tiff(&clear.clone().into(), &rgb), json!({}), icc_based(&rgb, 8, flate, true)), + ( + "RGB, resampled", + tiff(&colour(), &rgb), + json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}), + icc_based(&rgb, 8, flate, false), + ), + ( + "gray, resampled", + tiff(&colour().grayscale(), &gray), + json!({"compression": {"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}), + icc_based(&gray, 8, flate, false), + ), + ("RGB, JPEG", tiff(&colour(), &rgb), json!({"compression": {"color": {"compression": "jpeg"}}}), icc_based(&rgb, 8, "DCTDecode", false)), + ( + "gray, JPEG", + tiff(&colour().grayscale(), &gray), + json!({"compression": {"gray": {"compression": "jpeg"}}}), + icc_based(&gray, 8, "DCTDecode", false), + ), + ( + "PNG, resampled", + doc(tagged(&colour(), &rgb, ImageFormat::Png), "image/png"), + json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}), + icc_based(&rgb, 8, flate, false), + ), + ]; + for (what, d, settings, want) in cases { + assert_eq!(export(&d, settings), want, "{what}"); + } + // A gray image in an RGB profile stays RGB to keep it, resampled too. + let neutral = tiff(&DynamicImage::ImageRgb8(colour().grayscale().to_rgb8()), &rgb); + for settings in [json!({}), json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}})] { + assert_eq!(export(&neutral, settings.clone()), icc_based(&rgb, 8, flate, false), "neutral RGB, {settings}"); + } + // A profile of another colour space is left out. + let spaces = export(&tiff(&colour(), &gray), json!({})); + assert!(spaces.len() == 1 && spaces[0].space == "DeviceRGB" && spaces[0].icc.is_none(), "RGB in a gray profile: {spaces:?}"); + // Converted pixels are in the destination's colours, not the image's profile. + for destination in [vectorcraft_color::cms::DISPLAY_P3, vectorcraft_color::cms::GENERIC_CMYK] { + let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); + assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..5e9f3038f5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1783,6 +1783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). + - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, and a profile whose color space differs from the samples', are written without it. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). From 26c77228b37aa726db2f219081458e37a17a3506 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:06:28 -0800 Subject: [PATCH 22/82] Type: a tabbed right-to-left line with negative tracking stops reading after a bounded number of breaks Once a right-to-left line holds a tab and a glyph with a negative advance, the least it can be drawn at no longer grows with its tab stops, so a line that overflowed in the order of its text read on to the end of the paragraph before settling its break. Setting every line of a long paragraph that way took time quadratic in its length. Such a line now reads at most 256 legal breaks past where it first overflows in the order of its text, and ends at the latest break read that fits as drawn, the same limit the breaker uses for breaks it measures one by one. Lines without negative advances, and lines that fit, keep their breaks. --- crates/text/src/layout/breaking.rs | 21 ++++++- crates/text/src/layout/wrapping_tests.rs | 73 ++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 671ebee7a1..060a412aa4 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -253,7 +253,8 @@ pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_do } /// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one from the latest -/// back before bisecting the earlier ones. +/// back before bisecting the earlier ones, and most legal breaks a line with a glyph stepping back +/// reads past where it first overflows in the order of its text. const MAX_DRAWN_CHECKS: usize = 256; /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make @@ -358,7 +359,10 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// 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. +/// hyphen are bisected); hyphenation points are measured the same way. Once such a line holds a +/// glyph with a negative advance and has been wider than `width` in the order of its text, it reads +/// at most [`MAX_DRAWN_CHECKS`] legal breaks further and ends at the latest of the breaks read that +/// fits as drawn. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -426,6 +430,11 @@ pub(super) fn break_line( } }; let mut last_break: Option<(usize, bool)> = None; + // Whether the line, once it holds a tab and is measured as drawn, has been wider in the order of + // its text than `width`, and how many legal breaks it has read since then with a glyph stepping + // back: past MAX_DRAWN_CHECKS of them the line stops reading. + let mut overflowed = false; + let mut breaks_past_overflow = 0usize; // A list marker (length 0) is a cluster of its own; the line holds it and at least the first // cluster after it. let first = i + g.get(i..).unwrap_or_default().iter().take_while(|h| h.len == 0).count(); @@ -476,6 +485,7 @@ pub(super) fn break_line( // there are tabs. let stops_reached = if back + cluster_back > 0.0 { f64::NEG_INFINITY } else { nth_stop(tabs_read) - x_start }; let measured = if tabbed && fits.is_some() { (ink + cluster_ink).max(stops_reached) } else { x + cluster_width }; + overflowed |= tabbed && fits.is_some() && j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS; if j > first && !gl.is_space() && measured - end_credit > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { @@ -504,6 +514,13 @@ pub(super) fn break_line( if fits.is_some() { breaks.push((cluster_end, hyphenated)); } + // A line with a glyph stepping back stops reading MAX_DRAWN_CHECKS breaks past where + // it first overflowed in the order of its text, and ends at the latest break read + // that fits as drawn. + breaks_past_overflow += usize::from(overflowed && back > 0.0); + if breaks_past_overflow >= MAX_DRAWN_CHECKS { + return settle(tabbed, (cluster_end, hyphenated), &breaks); + } } } j = cluster_end; diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index eae22bab8a..4001496c19 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -646,6 +646,79 @@ fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() { assert_eq!(ended, (5, false)); } +/// A tabbed line of glyphs with negative advances, past the frame in the order of its text: `a`, +/// a tab to the default stop at 36 pt, `y` stepping back 1 pt, then `n` times `b` (1 pt), `z` +/// (−1 pt) and a space, so its glyphs other than the tab never fill the frame. Its breaks end the +/// line before glyph 2 and before 6 + 3u for each u. +fn stepping_back_tabbed_line(n: usize) -> (String, Vec) { + let text = format!("a\ty{}", "bz ".repeat(n)); + let specs: Vec<_> = text + .char_indices() + .map(|(byte, ch)| { + let adv = match ch { + 'y' | 'z' => -1.0, + ' ' => 0.0, + _ => 1.0, + }; + (byte, ch.len_utf8(), adv, ch) + }) + .collect(); + let glyphs = synthetic_glyphs(&specs); + (text, glyphs) +} + +/// A tabbed line with glyphs stepping back stops reading a bounded number of breaks past where it +/// overflows in the order of its text: a long paragraph is not read to its end to set one line. +#[test] +fn a_tabbed_line_stepping_back_stops_reading_past_its_overflow() { + let (text, glyphs) = stepping_back_tabbed_line(2000); + // The latest break the line reads is the first it measures as drawn. + let furthest = std::cell::Cell::new(0); + let measured = std::cell::Cell::new(0usize); + let drawn_fits = |end: usize, _hyphenated: bool| { + furthest.set(furthest.get().max(end)); + measured.set(measured.get() + 1); + end <= 10 + }; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (9, false)); + // 256 breaks past the overflow at `y`: the break before glyph 6 + 3 × 255. + assert_eq!(furthest.get(), 771, "read up to glyph {} of {}", furthest.get(), glyphs.len()); + assert!(measured.get() <= 300, "measured {} breaks as drawn", measured.get()); +} + +/// Where a tabbed line with glyphs stepping back stops reading, it ends at the latest break read +/// that fits as drawn, even when a break past those fits too. +#[test] +fn a_tabbed_line_stepping_back_ends_at_the_latest_break_read_that_fits() { + let (text, glyphs) = stepping_back_tabbed_line(400); + let end = |drawn_fits: &dyn Fn(usize, bool) -> bool| { + break_line( + &text, + &glyphs, + 0, + 30.0, + &[], + 0.0, + None, + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + Some(drawn_fits), + ) + }; + // The break before glyph 1101 is past the 256 breaks read after the overflow (up to 771). + assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 1101), (9, false)); + // A break among those read keeps its place. + assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 600), (600, false)); + assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 771), (771, false)); +} + /// 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..0d94781939 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -46,6 +46,7 @@ The shared assessment status line and revision history are current upstream; thi - File formats and Type core (#6147): `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text before a line's last tab at its stops, from the left edge, and the text after it against the right edge. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`; scores and estimates unchanged - Type core: a right-to-left line with tabs is measured in the order it is drawn, with tab stops still measured from the left edge. Each tab goes to its stop from where the glyphs drawn before it end, and right, center and decimal stops align the text drawn after the tab. Right, Auto and centered lines are aligned by that width, and area type wraps a line that is too wide when drawn. Evidence: `tests::justify::a_right_to_left_line_with_a_tab_is_aligned_as_it_is_drawn`, `tests::justify::a_right_to_left_line_with_a_tab_wraps_when_it_does_not_fit_as_drawn`, `tests::justify::right_to_left_tab_stops_align_the_text_drawn_after_them`, `tests::justify::a_right_to_left_line_with_two_tabs_and_a_latin_run_ends_on_the_right_edge`, `tests::justify::right_aligned_right_to_left_point_type_with_a_tab_ends_at_its_origin`; scores and estimates unchanged +- Type core: a right-to-left line with tabs and negative tracking that is wider than its frame in the order of its text reads at most 256 line breaks further and ends at the latest of them that fits as drawn, so a long paragraph is not read to its end to set each line. This is VectorCraft's own limit. Evidence: `layout::wrapping_tests::a_tabbed_line_stepping_back_stops_reading_past_its_overflow`, `layout::wrapping_tests::a_tabbed_line_stepping_back_ends_at_the_latest_break_read_that_fits`; scores and estimates unchanged - File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) From 77dc581b1c2cfb0a737fb79fb18efe621a27ce0c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:05:18 -0800 Subject: [PATCH 23/82] Type: Create Outlines keeps a run's gradient across the whole text Create Outlines copied each character run's fills and strokes onto every glyph, ruby glyph and underline or strikethrough bar as they were, so a gradient with no vector of its own refitted to each piece's own box, while the canvas fits it across the whole layout. A gradient with a vector kept its text-space coordinates in the document, and the characters' stroke kept its text-space weight on scaled type. Each piece is now painted by paint_type_piece, the rule outline_text already used: unplaced gradients are fixed to the layout's bounds, then the type's transform places the piece, its gradients, stroke weights and pattern tiles. Object > Expand outlines type the same way, and object.expand.info measures gradient fills on the same paths. --- crates/effects/src/lib.rs | 2 +- crates/effects/src/reshape.rs | 21 ++++++-- crates/engine/src/cmd/expand.rs | 18 +++---- crates/engine/src/cmd/typecmd.rs | 39 ++++++++------- crates/engine/src/tests/objexpand.rs | 72 ++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 6 files changed, 121 insertions(+), 32 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c18a31d842..ef7e4ce0e7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -82,7 +82,7 @@ pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item 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, + paint_type_piece, 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..c601ae0d5d 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -475,6 +475,20 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::Bez (runs, all, layout.bounds, colours) } +/// Paint `piece`, a piece of type's outline in text space (glyphs, ruby glyphs or an underline or +/// strikethrough bar), with `style`, the fills and strokes of its run, and place it in the document +/// by `xf`, the type's transform, as the renderer paints the run: its unplaced gradients are fixed +/// to `bounds`, the layout's bounds in text space, so every piece of the type shows the one +/// gradient fitted across the whole layout, and the transform also scales its stroke weights and +/// dashes and transforms its pattern tiles. Placed gradients keep their place in text space and +/// move with the type. +pub fn paint_type_piece(piece: &mut Node, style: &vectorcraft_doc::CharStyle, bounds: Rect, xf: Affine) { + let mut ap = style.appearance(); + ap.pin_gradients(bounds); + piece.appearance = ap.into(); + piece.transform(xf, vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() }); +} + /// Type as glyph outlines in document space: one path per run with the run's fill and stroke, /// over its colour glyphs (images, art), and (when the object has its own fills/strokes) the whole /// outline with the object's items below the characters under them and the others over them, as @@ -487,7 +501,6 @@ pub fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; let (runs, all, bounds, colours) = laid_out(t); let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf); - let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() }; let mut children: Vec> = vec![]; for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { // Colour glyphs paint themselves, under their run's fill, as the renderer paints them. @@ -495,10 +508,8 @@ pub fn outline_text(n: &Node) -> Option { if bp.elements().is_empty() { continue; } - let mut ap = run.style.appearance(); - ap.pin_gradients(bounds); - let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap); - piece.transform(t.xf, scaling); + let mut piece = Node::path(n.id, PathData::from_bezpath(bp), Appearance::default()); + paint_type_piece(&mut piece, &run.style, bounds, t.xf); children.push(Arc::new(piece)); } if !n.appearance.items.is_empty() && !all.elements().is_empty() { diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 02dbad5d23..43975fe4e4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -265,25 +265,27 @@ fn gradient_expands(d: &Document, l: &Node) -> bool { /// Whether type `t` has a gradient fill that Fill expands once Object has outlined it: one of the /// paths `type.createOutlines` makes of its glyphs, ruby glyphs and underline and strikethrough -/// bars, each painted as its run, has one ([`gradient_expands`] on that path's own box). Inline -/// graphics and color glyphs become other art. Leaves the document as it is and stops at the -/// first such path. +/// bars, each painted as its run with its unplaced gradients fitted to the layout's bounds, has one +/// ([`gradient_expands`] on that path). Inline graphics and color glyphs become other art. Leaves +/// the document as it is and stops at the first such path. fn text_gradient_expands(d: &Document, t: &TextObject) -> bool { if !t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))) { return false; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let first = t.first_style(); let expands = |run: usize, outline: &BezPath| { - let style = t.runs.get(run).map(|r| &r.style); - if !matches!(style.map_or_else(|| t.first_style().fill, |s| s.fill.clone()), Paint::Gradient(_)) { + let style = t.runs.get(run).map_or(&first, |r| &r.style); + if !matches!(style.fill, Paint::Gradient(_)) { return false; } - let path = PathData::from_bezpath(outline).transformed(t.xf); + let path = PathData::from_bezpath(outline); if path.is_empty() { return false; } - let appearance = style.map_or_else(|| t.first_style().appearance(), |s| s.appearance()); - gradient_expands(d, &Node::path(NodeId(u64::MAX), path, appearance)) + let mut piece = Node::path(NodeId(u64::MAX), path, Appearance::default()); + vectorcraft_render::effects::paint_type_piece(&mut piece, style, lay.bounds, t.xf); + gradient_expands(d, &piece) }; let inline = |gi: usize| lay.inlines.iter().find(|i| i.glyph == gi).is_some_and(|i| t.runs.get(i.run).is_some_and(|r| r.inline.is_some())); lay.glyphs.iter().enumerate().any(|(gi, g)| !inline(gi) && g.image.is_none() && g.colour.is_none() && expands(g.run, &g.outline)) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b3fab4a2bc..3d9298890c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -594,6 +594,21 @@ fn scale_text_attributes(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "changed": changed })) } +/// A path (or compound path) of `outline`, a piece of type `t`'s outline in text space, painted as +/// run `run` and placed in the document as the canvas paints it ([`paint_type_piece`]: unplaced +/// gradients fitted to `bounds`, the layout's bounds, stroke weights scaled by the type's +/// transform); `None` when the piece has no path. +fn type_piece(d: &mut Document, t: &TextObject, bounds: kurbo::Rect, run: usize, outline: &kurbo::BezPath) -> Option { + let path = PathData::from_bezpath(outline); + if path.is_empty() { + return None; + } + let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); + let mut node = shape_node(d, path, None); + vectorcraft_render::effects::paint_type_piece(&mut node, &st, bounds, t.xf); + Some(node) +} + fn create_outlines(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Create Outlines", |d, sel| { @@ -636,14 +651,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { children.push(Arc::new(d.reid(&art))); break 'glyph; } - let path = PathData::from_bezpath(&g.outline).transformed(t.xf); - if path.is_empty() { - break 'glyph; + if let Some(node) = type_piece(d, t, lay.bounds, g.run, &g.outline) { + children.push(Arc::new(node)); } - let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); - let mut node = shape_node(d, path, None); - node.appearance = st.appearance().into(); - children.push(Arc::new(node)); } run_end.insert(g.run, children.len()); } @@ -652,13 +662,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { // nothing there either). let mut ruby: Vec<(usize, Arc)> = vec![]; for g in &lay.ruby { - let path = PathData::from_bezpath(&g.outline).transformed(t.xf); - if path.is_empty() { - continue; - } - let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); - let mut node = shape_node(d, path, None); - node.appearance = st.appearance().into(); + let Some(node) = type_piece(d, t, lay.bounds, g.run, &g.outline) else { continue }; // After the glyphs of its run and of the runs before it. let at = run_end.range(..=g.run).map(|(_, end)| *end).max().unwrap_or(0); ruby.push((at, Arc::new(node))); @@ -683,10 +687,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { // Underline and strikethrough bars become paths of their run's paint too (#847). for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) { all.extend(bar.iter()); - let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); - let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None); - node.appearance = st.appearance().into(); - children.push(Arc::new(node)); + if let Some(node) = type_piece(d, t, lay.bounds, run, &bar) { + children.push(Arc::new(node)); + } } // The object's own fills and strokes (not its characters') paint the whole outline: // those below the Characters row under the characters, the others over them, with diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index c8b51033fe..91b9c356c4 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -754,6 +754,78 @@ fn create_outlines_draws_the_type_objects_own_paint() { assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something"); } +/// Selected type "HIH" at 40 pt with ruby, underlined and struck through, its characters filled +/// with `fill` (a `paint.setFill` param) and stroked 2 pt wide, then scaled to 150% and rotated 20 +/// degrees (the stroke keeps its 2 pt, which is 4/3 pt in text space). +fn transformed_gradient_type(s: &mut Session, fill: Value) -> NodeId { + let t = run(s, "text.create", json!({"x": 30, "y": 110, "text": "HIH", "size": 40}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [t]})); + run(s, "type.ruby", json!({"text": "ab"})); + run(s, "text.setFormat", json!({"ids": [t], "underline": true, "strikethrough": true})); + run(s, "paint.setFill", fill); + run(s, "paint.setStroke", json!({"color": "#008000"})); + run(s, "stroke.set", json!({"weight": 2})); + run(s, "object.scale", json!({"ids": [t], "sx": 150, "sy": 150})); + run(s, "object.rotate", json!({"ids": [t], "angle": 20})); + NodeId(t) +} + +/// Create Outlines draws what the type drew: a gradient on the characters that has no vector of +/// its own keeps one gradient across every glyph, ruby glyph and bar, fitted to the layout's box, +/// and one with a vector keeps it where it was, both with the type's scale and rotation. The +/// characters' stroke keeps the weight it was drawn at. +#[test] +fn create_outlines_keeps_a_character_gradient_across_the_type() { + let placed = json!({"gradient": {"kind": "linear", "start": [40, 100], "end": [110, 100], + "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}); + for fill in [red_blue(), placed] { + let mut s = session(); + let t = transformed_gradient_type(&mut s, fill.clone()); + let NodeKind::Text(text) = &node(&s, t).kind else { panic!("not type") }; + assert!(!text.ruby.is_empty() && text.runs.iter().all(|r| r.style.underline && r.style.strikethrough), "{fill}"); + // The characters' stroke is kept in text space and drawn scaled by the type's transform. + let weight = text.runs[0].style.stroke_layer().width; + let drawn = weight * vectorcraft_doc::Scaling::factor(text.xf).unwrap_or(1.0); + assert!((drawn - weight).abs() > 0.1, "the type's transform scales: {weight} {drawn}"); + let before = render(&s); + let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap(); + let after = render(&s); + let n = differing(&before, &after, 24); + assert!(n < 60, "{fill}: {n} pixels differ"); + assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something"); + // Every glyph, ruby glyph and bar takes the same gradient, placed, and the stroke at the + // weight it was drawn at. + let mut gradients = vec![]; + let mut widths = vec![]; + node(&s, NodeId(g)).walk(&mut |c: &Node| { + if let Paint::Gradient(gr) = c.appearance.fill_paint() { + gradients.push(gr.clone()); + } + widths.extend(c.appearance.stroke().map(|st| st.width)); + }); + assert!(gradients.len() >= 6, "{fill}: three glyphs, ruby and two bars: {}", gradients.len()); + assert!(gradients.iter().all(|gr| gr.is_placed() && *gr == gradients[0]), "{fill}: {gradients:?}"); + assert!(widths.len() == gradients.len() && widths.iter().all(|w| (w - drawn).abs() < 1e-9), "{fill}: {drawn} {widths:?}"); + } +} + +/// Expand outlines the type before its fills expand, so a gradient on its characters that has no +/// vector of its own expands across the whole type, as the canvas draws it, and the query reports +/// it. +#[test] +fn expand_keeps_a_character_gradient_across_the_type() { + let mut s = session(); + transformed_gradient_type(&mut s, red_blue()); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone(), info["object"].clone()), (json!(true), json!(true), json!(true)), "{info}"); + let before = render(&s); + run(&mut s, "object.expand", json!({})); + let after = render(&s); + assert_eq!(gradients_and_type(&s), (0, 0), "outlined and expanded"); + let d = mean_diff(&before, &after); + assert!(d < 0.5, "{d}"); +} + /// `object.expand.info` reports the type object's own stroke, pattern fill and gradient fill, which /// Object turns into paths of its whole outline: a gradient only when that outline's box needs no /// more runs than are written. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..bc91cca665 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -678,6 +678,7 @@ since 2026-10-01. - **COLR colour fonts:** v0 and v1 glyphs draw as vector layers (solid colours, linear and radial gradients, including ones that repeat or reflect, clips, blend modes, the foreground colour from the run's fill) on the canvas, as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as Type 3 text in PDF, and as editable groups with Create Outlines; sweep gradients, Porter-Duff compositing and radial gradients that repeat or reflect and start at a circle draw the glyph as an image. Measured by `tests_colr`, `render::tests_colrglyphs`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colr_glyphs_*`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups`. Scores unchanged (estimated). - **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, ); a type object's appearance is separate from its characters' (the help page, ); 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). +- **Create Outlines keeps the characters' gradients across the type:** Type › Create Outlines and Object › Expand give every glyph, ruby glyph and underline or strikethrough bar the gradient its characters drew with: a gradient with no vector of its own stays fitted to the whole layout rather than to each glyph, and one with a vector keeps its place, both moved, scaled and rotated with the type; the characters' stroke keeps the weight it was drawn at on scaled type. `object.expand.info` measures gradient fills on the same paths. Outlined characters keep their stroke and fill (the help page, ); the gradient fitted across the whole layout is VectorCraft's own rule. Measured by `tests::objexpand::create_outlines_keeps_a_character_gradient_across_the_type` and `tests::objexpand::expand_keeps_a_character_gradient_across_the_type`. 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). - **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. From 8fa551e173e00f7d584913cf6d3e3298f563fe93 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:55:28 -0800 Subject: [PATCH 24/82] Selection: clicks in a clip group follow the region its clipping path's effects give it The canvas clips a clip group by its clipping path with the path's live geometry effects applied, but clicks, hovers and marquees still tested the plain clipping path. A click where a Transform effect had moved the clip selected nothing, and a click on the clipping path's own place selected art that was not drawn there. - The engine registers the effect-shaped region with the doc crate (`hit::set_clip_region`), as it does for image paint and live art. It gives a region only when the clipping path holds a visible geometry effect; otherwise clicks keep the plain shapes, and type keeps counting by its frame. - Clicks, `objects_at`, the hidden art of a selected object, the Unlock submenu's list and marquees inside a clip group or a clipping layer use that region. Outline view and layers with Preview off draw no effects and keep the plain shapes; the Selection, Direct Selection and Enclosing Lasso marquees pass the view's outline state (`hit::marquee_in_view`). Each container around a selected object tests its clipping path in the outline state a click has there. - A marquee touches the clipping path itself by its own geometry, as a click on its outline finds it, and also on the edge of the region, so a marquee around a clipping path that an effect moved into large art still selects it. `hit::marquee_in` keeps the plain shapes for the Color Theme Picker, which clips by them. - The region is kept per thread until the clipping path or the live inputs change. A clip group's reach takes in the region's box, so culling does not skip art the region moved past the plain clipping path. Up to 4096 boxes are kept; when they are full, a new box replaces only as many others as it needs room for. Docs: docs/target-app-parity.md. --- crates/doc/src/hit.rs | 368 +++++++++++++++--- crates/doc/src/hit/tests.rs | 239 ++++++++++++ crates/doc/src/node/ops.rs | 9 +- crates/engine/src/session/documents.rs | 13 + crates/engine/src/tests/clip.rs | 42 ++ crates/tools/src/addons/enclosing_lasso.rs | 2 +- .../tools/src/addons/enclosing_lasso/tests.rs | 22 ++ crates/tools/src/direct.rs | 4 +- crates/tools/src/direct/tests.rs | 23 ++ crates/tools/src/lib.rs | 30 ++ crates/tools/src/select.rs | 4 +- crates/tools/src/select/tests.rs | 19 + docs/target-app-parity.md | 1 + 13 files changed, 713 insertions(+), 63 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index fd907a26cc..c0a80a8964 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1,7 +1,7 @@ //! Hit testing over the document tree. use std::cell::RefCell; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::sync::{Arc, OnceLock}; @@ -279,13 +279,16 @@ pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: & return false; } // How a click reaches it, if one does. + let start = opt; let Some(opt) = path_options(&nodes, &chain, opt) else { return false }; - // Hidden by a mask: one of the containers around it clips, and not to `p`. - let masked = nodes - .iter() - .rev() - .skip(1) - .any(|n| n.clips() && n.children().and_then(|c| c.first()).is_some_and(|clip| clip.id != id && !clip_contains(clip, p))); + // Hidden by a mask: one of the containers around it clips, and not to `p`. A container + // tests its clipping path in outline from the first layer with Preview off down to it, as + // a click going down to it does ([`path_options`]). + let below = start.scope.and_then(|s| chain.iter().position(|a| *a == s)).map_or(0, |i| i + 1); + let masked = nodes.iter().enumerate().rev().skip(1).any(|(k, n)| { + let outline = start.outline || nodes.get(below..=k).is_some_and(|ns| ns.iter().any(|n| previews_off(n))); + n.clips() && n.children().and_then(|c| c.first()).is_some_and(|clip| clip.id != id && !clip_contains(clip, p, outline)) + }); // A click may reach it there ([`may_hit`]: not a guide while guides are locked or // hidden, nor a Shaper group's hidden part), and its own art is what a click would hit // in it: a group's hidden, locked or masked contents don't count. @@ -359,6 +362,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option, seen: &mut HashSet) -> bool { let Some(children) = layer.children() else { return true }; + let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; if layer.clips() && let Some(clip) = children.first() { @@ -366,11 +370,10 @@ fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox if clip_outline_hit(layer, clip, p, opt, symbols, true).is_some() && seen.insert(clip.id) { tops.push(clip.id); } - if !clip_contains(clip, p) { + if !clip_contains(clip, p, opt.outline) { return true; } } - let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; for (i, c) in children.iter().enumerate().rev() { if !may_hit(layer, i, c, p, opt, symbols, true) || seen.contains(&c.id) { continue; @@ -452,7 +455,7 @@ pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec) { let Some(children) = parent.children() else { return }; - if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p)) { + if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p, opt.outline)) { return; } for i in hit_order(parent, children, p) { @@ -561,14 +564,170 @@ fn edits_contents(n: &Node) -> bool { matches!(n.kind, NodeKind::Envelope { editing: true, .. }) } -/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame: -/// glyph outlines need the font engine, above this crate. -fn clip_contains(clip: &Node, p: Point) -> bool { - clip_regions(clip).iter().any(|(bp, rule)| fill_contains(bp, *rule, p)) +/// Is `p` inside the region `clip` clips to ([`clip_areas`])? In outline mode ([`HitOptions::outline`]) +/// the canvas draws no effects, so the plain shapes of `clip` ([`clip_regions`]) count. +fn clip_contains(clip: &Node, p: Point, outline: bool) -> bool { + let areas = if outline { clip_regions(clip) } else { clip_areas(clip) }; + areas.iter().any(|(bp, rule)| fill_contains(bp, *rule, p)) } -/// The regions `clip` clips to ([`Node::clip_shapes`]), text counted by its frame as in -/// [`clip_contains`]. +/// The region of a clip group's clipping path as the canvas clips by it, with the live geometry +/// effects of the clipping path and of what it clips by applied, as one path and fill rule +/// (`Some(None)` when nothing is left to clip by). `None` when nothing in the clipping path has a +/// live geometry effect: its plain shapes ([`clip_regions`]) count then. Effects are evaluated +/// above this crate, so the engine registers one with [`set_clip_region`]. +pub type ClipRegion = fn(&Node) -> Option>; + +static CLIP_REGION: OnceLock = OnceLock::new(); + +/// Register how the region of a clipping path with live effects is found (the first registration +/// stays). +pub fn set_clip_region(f: ClipRegion) { + // Already set: the same engine, registered by another session. + let _ = CLIP_REGION.set(f); +} + +/// Does `clip`, or anything it holds, have a visible effect of its own or on a fill or stroke? Only +/// then can the registered [`ClipRegion`] find a geometry effect in it. +fn has_effects(clip: &Node) -> bool { + let mut found = false; + clip.walk(&mut |n| { + let visible = |e: &crate::Effect| e.visible; + found |= n.appearance.effects.iter().any(visible) || n.appearance.items.iter().any(|i| i.effects().iter().any(visible)); + }); + found +} + +/// A clipping path's region evaluated by [`clip_region`] on this thread: the clipping path and the +/// [`LiveInputs`] as they were, the region (`None` without geometry effects) and the number of its +/// path elements. +struct EvaluatedClip { + clip: Node, + inputs: u64, + region: Option>>, + size: usize, +} + +/// How many regions [`clip_region`] keeps on each thread: every click and every hover over a clip +/// group whose clipping path has live effects asks for its region, and clip groups can be nested. +const CLIPS_KEPT: usize = 16; + +thread_local! { + /// The regions [`clip_region`] keeps, the latest first. + static CLIPS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[cfg(test)] +thread_local! { + /// How many regions [`clip_region`] evaluated on this thread. + static CLIPS_EVALUATED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The region clipping path `clip` clips to with its live geometry effects applied +/// ([`set_clip_region`]), or `None` when it has none or nothing is registered; its plain shapes +/// ([`clip_regions`]) are the region then. The region is evaluated again when `clip` or the +/// [`LiveInputs`] have changed since. +fn clip_region(clip: &Node) -> Option>> { + let f = CLIP_REGION.get()?; + if !has_effects(clip) { + return None; + } + let inputs = live_inputs(); + let kept = CLIPS.with_borrow_mut(|kept| { + let i = kept.iter().position(|e| e.inputs == inputs && e.clip == *clip)?; + let e = kept.remove(i); + let region = e.region.clone(); + kept.insert(0, e); + Some(region) + }); + if let Some(region) = kept { + return region; + } + #[cfg(test)] + CLIPS_EVALUATED.with(|c| c.set(c.get() + 1)); + let region = f(clip).map(Rc::new); + let elements = region.as_deref().and_then(Option::as_ref).map_or(0, |(bp, _)| bp.elements().len()); + let size = elements.saturating_add(art_size(std::slice::from_ref(clip))); + if size <= LIVE_KEPT_SIZE { + let e = EvaluatedClip { clip: clip.clone(), inputs, region: region.clone(), size }; + CLIPS.with_borrow_mut(|kept| { + kept.insert(0, e); + kept.truncate(CLIPS_KEPT); + let mut total = 0usize; + kept.retain(|e| { + total = total.saturating_add(e.size); + total <= LIVE_KEPT_SIZE + }); + }); + } + region +} + +/// The box of a clipping path's region found by [`reshaped_clip_box`]: the clipping path and the +/// [`LiveInputs`] as they were, the box and the [`art_size`] of the clipping path. +struct ClipBox { + clip: Node, + inputs: u64, + rect: Option, + size: usize, +} + +/// How many boxes [`reshaped_clip_box`] keeps on each thread. Every pointer move measures every clip +/// group it passes, so the boxes of all of a document's clip groups with live effects are kept, not +/// only the latest few as with the regions. +const CLIP_BOXES_KEPT: usize = 4096; + +thread_local! { + /// The boxes [`reshaped_clip_box`] keeps, by clipping path, and the sum of their sizes. + static CLIP_BOXES: RefCell<(HashMap, usize)> = RefCell::new((HashMap::new(), 0)); +} + +/// The box of the region `clip` clips to with its live geometry effects applied ([`clip_region`]), +/// `None` when it has none or no region. The box is kept until `clip` or the [`LiveInputs`] change. +/// When the boxes kept are full, a new box replaces as many others as it needs room for, so the +/// pointer moving over more clip groups than fit still finds most of their boxes. +pub(crate) fn reshaped_clip_box(clip: &Node) -> Option { + use vectorcraft_geom::kurbo::Shape; + CLIP_REGION.get()?; + if !has_effects(clip) { + return None; + } + let inputs = live_inputs(); + let kept = CLIP_BOXES.with_borrow(|(boxes, _)| boxes.get(&clip.id).filter(|b| b.inputs == inputs && b.clip == *clip).map(|b| b.rect)); + if let Some(rect) = kept { + return rect; + } + let rect = clip_region(clip).and_then(|r| r.as_ref().as_ref().filter(|(bp, _)| !bp.elements().is_empty()).map(|(bp, _)| bp.bounding_box())); + let size = art_size(std::slice::from_ref(clip)); + if size <= LIVE_KEPT_SIZE { + CLIP_BOXES.with_borrow_mut(|(boxes, total)| { + if let Some(old) = boxes.remove(&clip.id) { + *total = total.saturating_sub(old.size); + } + while boxes.len() >= CLIP_BOXES_KEPT || total.saturating_add(size) > LIVE_KEPT_SIZE { + let Some(other) = boxes.keys().next().copied() else { break }; + if let Some(old) = boxes.remove(&other) { + *total = total.saturating_sub(old.size); + } + } + boxes.insert(clip.id, ClipBox { clip: clip.clone(), inputs, rect, size }); + *total = total.saturating_add(size); + }); + } + rect +} + +/// The regions a click inside a clip group must fall in: those `clip` clips to with its live +/// effects applied, as the canvas clips ([`clip_region`]), else its plain shapes ([`clip_regions`]). +fn clip_areas(clip: &Node) -> Vec<(BezPath, FillRule)> { + match clip_region(clip) { + Some(region) => region.as_ref().iter().cloned().collect(), + None => clip_regions(clip), + } +} + +/// The regions `clip` clips to by its plain shapes ([`Node::clip_shapes`]), without its live +/// effects. Text counts by its frame: glyph outlines need the font engine, above this crate. pub fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> { let frame = |n: &Node| { let b = n.geometric_bounds()?; @@ -698,7 +857,7 @@ fn hit_children( // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it, except for // the selection tools in isolation mode within it. let edited = opt.clip_outlines && editable_only && edits_inside(parent, opt.scope); - if !edited && !clip_contains(clip, p) { + if !edited && !clip_contains(clip, p, opt.outline) { return None; } } @@ -960,7 +1119,7 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option match &n.kind { // A clip group only hits inside its clipping path. NodeKind::Group { children, clip } => { - if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) { + if *clip && children.first().is_some_and(|c| !clip_contains(c, p, opt.outline)) { return None; } children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)) @@ -1081,7 +1240,7 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> path.push(0); return true; } - if !clip_contains(&clip, self.p) { + if !clip_contains(&clip, self.p, self.opt.outline) { return false; } } @@ -1244,31 +1403,48 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// or of the parent of the single object isolated, does not limit the marquee, as it does not limit /// the selection tools' clicks. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { - marquee_with(doc, r, scope, leaves, false) + marquee_with(doc, r, scope, leaves, false, false) +} + +/// [`marquee`] in the view the canvas shows: with `outline` (Outline view), and below a layer +/// whose Preview is off, a clipping path counts by its plain shapes, without its live effects, as +/// the clicks of [`HitOptions::outline`] do. +pub fn marquee_in_view(doc: &Document, r: Rect, scope: Option, leaves: bool, outline: bool) -> Vec { + marquee_with(doc, r, scope, leaves, false, outline) } /// Candidates for whole-object enclosure by a loop bounded by `r`. Eligibility and group -/// traversal follow [`marquee`]; compounds contribute the complete path they paint. -pub fn enclosure_candidates(doc: &Document, r: Rect, scope: Option) -> Vec { - marquee_with(doc, r, scope, true, true) +/// traversal follow [`marquee_in_view`]; compounds contribute the complete path they paint. +pub fn enclosure_candidates(doc: &Document, r: Rect, scope: Option, outline: bool) -> Vec { + marquee_with(doc, r, scope, true, true, outline) } -fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, whole_compounds: bool) -> Vec { +/// Is `n` a layer whose Preview is off (its art is drawn and clicked in outline)? +fn previews_off(n: &Node) -> bool { + matches!(n.kind, NodeKind::Layer { preview: false, .. }) +} + +fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, whole_compounds: bool, outline: bool) -> Vec { let mut out = Vec::new(); - // `tops` pairs each object with the part of `r` in which the marquee can touch it. - let tops: Vec<(&std::sync::Arc, Rect)> = match scope.and_then(|s| doc.node(s)) { + // The objects the marquee may touch ([`Top`]). + let tops: Vec = match scope.and_then(|s| doc.node(s)) { // Isolated art whose layer, or a group around it, was locked or hidden afterwards: the // marquee touches none of it, as clicks don't (#5309). Some(s) if !doc.is_editable(s.id) => vec![], Some(s) => match s.children() { - Some(c) => c.iter().enumerate().filter(|(i, c)| !c.is_ruler_guide() && !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), + Some(c) => c + .iter() + .enumerate() + .filter(|(i, c)| !c.is_ruler_guide() && !shaper_hides(s, *i, scope)) + .map(|(_, c)| (c, Some(r), outline, None)) + .collect(), // A single object isolated: only it. None => doc .parent_of(s.id) .and_then(|p| doc.node(p)) .and_then(|p| p.children()) .into_iter() - .flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, r))) + .flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, Some(r), outline, None))) .collect(), }, // The objects of the layers, looking through sublayers (hidden, locked and template ones @@ -1277,42 +1453,88 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh .layers .iter() .filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id)) - .flat_map(|l| layer_objects(l, r)) + .flat_map(|l| layer_objects(l, r, outline)) .collect(), }; /// The objects of layer `l`, each with the part of `r` that the clipping masks of `l` and of - /// the sublayers it is in leave. - fn layer_objects(l: &Node, r: Rect) -> Vec<(&std::sync::Arc, Rect)> { + /// the sublayers it is in leave, whether it is in outline, and for the clipping mask of `l`, + /// `r` for its edge test. The clipping mask itself counts by its own geometry within its plain + /// shapes ([`mask_marquee`]). + fn layer_objects(l: &Node, r: Rect, outline: bool) -> Vec> { let mut out = vec![]; - let Some(r) = marquee_in(l, r) else { return out }; - for c in l.children().into_iter().flatten() { + let outline = outline || previews_off(l); + let inner = marquee_in_parent(l, r, outline); + for (i, c) in l.children().into_iter().flatten().enumerate() { if c.is_layer() { - if c.visible && !c.locked && !c.is_template() { - out.extend(layer_objects(c, r)); + if let Some(inner) = inner + && c.visible + && !c.locked + && !c.is_template() + { + out.extend(layer_objects(c, inner, outline)); } } else if !c.is_ruler_guide() { - out.push((c, r)); + if i == 0 && l.clips() { + out.push((c, mask_marquee(l, r), outline, Some(r))); + } else if inner.is_some() { + out.push((c, inner, outline, None)); + } } } out } + /// The part of `r` in which the marquee can touch the clipping path of `parent` by its own + /// geometry: within the bounds of its plain shapes ([`marquee_in`]), where clicks find its + /// outline too ([`clip_outline_hit`]), whatever its live effects do to the region it clips to. + fn mask_marquee(parent: &Node, r: Rect) -> Option { + marquee_in_parent(parent, r, true) + } + /// Does the marquee `r` touch `clip`, the clipping path of a clip group or of a layer with a + /// clipping mask, on the edge of the region its live effects give it ([`clip_region`]), where + /// the clip shows? Never in outline, nor without live effects: its own geometry counts then. + fn clip_edge_touches(clip: &Node, r: Rect, outline: bool) -> bool { + if outline { + return false; + } + clip_region(clip).is_some_and(|region| { + region.as_ref().as_ref().is_some_and(|(bp, _)| { + // The edge of the region is its outline with each open subpath closed, as `fill_contains` fills it. + let mut edge = PathData::from_bezpath(bp); + edge.subpaths.iter_mut().for_each(|sp| sp.closed = true); + vectorcraft_geom::hit::intersects_rect(&edge, r) + }) + }) + } + /// Is `n` collected whole by [`collect_leaves`], not by its members? + fn is_leaf(n: &Node) -> bool { + !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Intertwine { .. }) + } /// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip /// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched /// by the copies it draws, as clicks hit them. Objects of every other kind except paths, - /// compound paths, layers and groups are touched by their box. - fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool) -> bool { + /// compound paths, layers and groups are touched by their box. A clipping path is touched by + /// its own geometry ([`mask_marquee`]) and on the edge of its region ([`clip_edge_touches`]). + fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, outline: bool) -> bool { match &n.kind { NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r), NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { - marquee_in(n, r).is_some_and(|r| { - children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope, whole_compounds)) + let outline = outline || previews_off(n); + let inner = marquee_in_parent(n, r, outline); + children.iter().enumerate().any(|(i, c)| { + c.visible + && !shaper_hides(n, i, scope) + && if i == 0 && n.clips() { + mask_marquee(n, r).is_some_and(|r| touches(c, s, r, scope, whole_compounds, outline)) || clip_edge_touches(c, r, outline) + } else { + inner.is_some_and(|r| touches(c, s, r, scope, whole_compounds, outline)) + } }) } NodeKind::Compound { children, .. } if whole_compounds => { children.iter().filter_map(|c| c.path_data()).any(|path| vectorcraft_geom::hit::intersects_rect(path, r)) } - NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds)), - NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds)), + NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds, outline)), + NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds, outline)), // A box with no width or no height (an instance of a symbol that draws a straight // line) is touched where the marquee crosses it. _ => n.geometric_bounds_with(s).is_some_and(|b| { @@ -1321,18 +1543,30 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh }), } } - fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, out: &mut Vec) { + fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, outline: bool, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { - let Some(r) = marquee_in(n, r) else { return }; + let outline = outline || previews_off(n); + let inner = marquee_in_parent(n, r, outline); for (i, c) in children.iter().enumerate() { - if c.visible && !c.locked && !shaper_hides(n, i, scope) { - collect_leaves(c, s, r, scope, whole_compounds, out); + if !c.visible || c.locked || shaper_hides(n, i, scope) { + continue; + } + if i == 0 && n.clips() { + let before = out.len(); + if let Some(r) = mask_marquee(n, r) { + collect_leaves(c, s, r, scope, whole_compounds, outline, out); + } + if out.len() == before && is_leaf(c) && clip_edge_touches(c, r, outline) { + out.push(c.id); + } + } else if let Some(r) = inner { + collect_leaves(c, s, r, scope, whole_compounds, outline, out); } } } _ => { - if touches(n, s, r, scope, whole_compounds) { + if touches(n, s, r, scope, whole_compounds, outline) { out.push(n.id); } } @@ -1340,36 +1574,56 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh } let symbols = doc.symbol_boxes(); let locks = doc.locked_artboards(); - for (t, r) in tops { + for (t, r, outline, edge) in tops { if !t.visible || t.locked || locks.holds_id(t.id) { continue; } if leaves { - collect_leaves(t, &symbols, r, scope, whole_compounds, &mut out); - } else if touches(t, &symbols, r, scope, whole_compounds) { + let before = out.len(); + if let Some(r) = r { + collect_leaves(t, &symbols, r, scope, whole_compounds, outline, &mut out); + } + if edge.is_some_and(|e| out.len() == before && is_leaf(t) && clip_edge_touches(t, e, outline)) { + out.push(t.id); + } + } else if r.is_some_and(|r| touches(t, &symbols, r, scope, whole_compounds, outline)) + || edge.is_some_and(|e| clip_edge_touches(t, e, outline)) + { out.push(t.id); } } out } +/// An object a marquee may touch ([`marquee_with`]): the object, the part of the marquee in which +/// it can be touched (`None` when nowhere), whether it is in outline, and for the clipping mask of a +/// layer, the marquee whose edge test it also takes (`clip_edge_touches`). +type Top<'a> = (&'a std::sync::Arc, Option, bool, Option); + /// The part of a marquee `r` in which it can touch the children of `parent`. For a clip group or a -/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for its clipping path (None -/// when the marquee misses it); otherwise it is all of `r`. +/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for the plain shapes of its +/// clipping path ([`clip_regions`]; None when the marquee misses them); otherwise it is all of `r`. pub fn marquee_in(parent: &Node, r: Rect) -> Option { + marquee_in_parent(parent, r, true) +} + +/// The part of a marquee `r` in which it can touch the children of `parent`, by the region its +/// clipping path clips to ([`clip_areas`]), or by that path's plain shapes with `outline` +/// ([`marquee_in_view`]). +fn marquee_in_parent(parent: &Node, r: Rect, outline: bool) -> Option { match parent.children().and_then(|c| c.first()) { - Some(clip) if parent.clips() => marquee_in_clip(clip, r), + Some(clip) if parent.clips() => marquee_in_clip(clip, r, outline), _ => Some(r), } } /// The part of a marquee `r` in which it can touch the art that `clip` clips (the clipping path of /// a clip group or of a layer with a clipping mask): `r` cut to the bounds of the regions `clip` -/// clips to ([`clip_regions`]), or `None` when `r` meets none of those regions. Within those -/// bounds, art outside a region that is not a rectangle still counts. -fn marquee_in_clip(clip: &Node, r: Rect) -> Option { +/// clips to ([`clip_areas`], its plain shapes with `outline`), or `None` when `r` meets none of +/// those regions. Within those bounds, art outside a region that is not a rectangle still counts. +fn marquee_in_clip(clip: &Node, r: Rect, outline: bool) -> Option { use vectorcraft_geom::kurbo::Shape; - let regions = clip_regions(clip); + let regions = if outline { clip_regions(clip) } else { clip_areas(clip) }; // The edge of a region is its outline with each open subpath closed, as `fill_contains` fills it. let meets = |(bp, rule): &(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)| { let mut edge = vectorcraft_geom::PathData::from_bezpath(bp); diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 3eac4202b6..4584421200 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -110,6 +110,245 @@ fn half_clipped(d: &mut Document) -> (Node, Node) { (mask, art) } +/// A stand-in for the engine's [`ClipRegion`]: with an effect named `test.move` in the clipping +/// path, its first shape moved 100 pt right; without one, no geometry effect. +fn moved_clip_region(clip: &Node) -> Option> { + let mut moves = false; + clip.walk(&mut |n| moves |= n.appearance.effects.iter().any(|e| e.visible && e.id == "test.move")); + if !moves { + return None; + } + Some(clip_regions(clip).into_iter().next().map(|(mut bp, rule)| { + bp.apply_affine(vectorcraft_geom::Affine::translate((100.0, 0.0))); + (bp, rule) + })) +} + +/// A visible effect named `test.move`, which [`moved_clip_region`] reads as moving the region. +fn test_move() -> crate::Effect { + crate::Effect { id: "test.move".into(), params: serde_json::json!({}), visible: true } +} + +/// Clicks inside a clip group whose clipping path has a live effect fall in the region the +/// registered [`ClipRegion`] gives it. The pointer moving over it evaluates the region once, and +/// again after the clipping path changes; a clipping path without effects is not evaluated. +#[test] +fn clicks_in_a_clip_group_evaluate_its_clipping_paths_region_once_until_it_changes() { + set_clip_region(moved_clip_region); + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mut mask, art) = half_clipped(&mut d); + mask.appearance.effects.push(crate::Effect { id: "test.move".into(), params: serde_json::json!({}), visible: true }); + let (g, mask_id, art_id) = (d.alloc_id(), mask.id, art.id); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + let evaluated = || CLIPS_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + let leaf = |d: &Document, x: f64| hit_test(d, Point::new(x, 50.0), HitOptions::default()).map(|h| h.leaf); + for x in [148.0, 150.0, 152.0] { + assert_eq!(leaf(&d, x), Some(art_id), "in the moved region"); + } + assert_eq!(leaf(&d, 50.0), None, "the clipping path's own place"); + assert_eq!(evaluated(), before + 1); + // Outline mode draws no effects: the art's edge shows within the clipping path's own shape. + let outline = HitOptions { outline: true, ..HitOptions::default() }; + assert_eq!(hit_test(&d, Point::new(50.0, 0.5), outline).map(|h| h.leaf), Some(art_id), "outline mode"); + // So with a marquee across the art's left edge: in the view, only in Outline view. The + // clipping path's own left edge lies there too, and counts in both. + let edge = Rect::new(-5.0, 40.0, 5.0, 60.0); + assert_eq!(marquee_in_view(&d, edge, None, true, false), [mask_id]); + assert_eq!(marquee_in_view(&d, edge, None, true, true), [mask_id, art_id]); + // `marquee_in` measures by the plain shapes, as its callers clip by them. + assert!(marquee_in(d.node(g).unwrap(), edge).is_some()); + // Hidden, the effect no longer moves the region. + d.node_mut(mask_id).unwrap().appearance.effects[0].visible = false; + assert_eq!(leaf(&d, 50.0), Some(art_id)); + assert_eq!(leaf(&d, 150.0), None); + assert_eq!(evaluated(), before + 1); + // Shown again and widened, the region is evaluated again. + let mask = d.node_mut(mask_id).unwrap(); + mask.appearance.effects[0].visible = true; + if let NodeKind::Path { path, .. } = &mut mask.kind { + *path = shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 100.0)); + } + assert_eq!(leaf(&d, 180.0), Some(art_id)); + assert_eq!(leaf(&d, 195.0), None); + assert_eq!(evaluated(), before + 2); +} + +/// A marquee around a clipping path that an effect moved into the middle of large art touches the +/// clipping path on the edge of its moved region, in a clip group and in a clipping layer. +#[test] +fn a_marquee_touches_a_moved_clipping_path_on_its_region() { + set_clip_region(moved_clip_region); + let parts = |d: &mut Document| { + let mut mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default()); + mask.appearance.effects.push(crate::Effect { id: "test.move".into(), params: serde_json::json!({}), visible: true }); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 300.0)), Appearance::default_art()); + (mask, art) + }; + // Around the moved region (120..160) and the clipping path's own place, inside the art. + let around = Rect::new(10.0, 10.0, 170.0, 70.0); + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let (mask, art) = parts(&mut d); + let (g, mask_id) = (d.alloc_id(), mask.id); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + assert_eq!(marquee(&d, around, None, false), [g]); + assert_eq!(marquee(&d, around, None, true), [mask_id]); + // Only over the clipping path's own place, the marquee misses the region but touches the + // clipping path's anchors, which a click on its outline also finds. + let own = Rect::new(10.0, 10.0, 70.0, 70.0); + assert_eq!(marquee(&d, own, None, false), [g]); + assert_eq!(marquee(&d, own, None, true), [mask_id]); + // Away from both, nothing. + assert!(marquee(&d, Rect::new(70.0, 10.0, 110.0, 70.0), None, true).is_empty()); + + let mut d = Document::new(400.0, 400.0); + let top = d.layers[0].id; + let (mask, art) = parts(&mut d); + let mask_id = mask.id; + let mut sublayer = (*d.layers[0]).clone(); + sublayer.id = d.alloc_id(); + if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind { + (*children, *clip) = (vec![Arc::new(mask), Arc::new(art)], true); + } + d.insert(Some(top), 0, sublayer).unwrap(); + assert_eq!(marquee(&d, around, None, false), [mask_id]); +} + +/// A clipping layer whose Preview is off draws its art in outline: clicks, the objects at a point +/// and marquees count its clipping path by its plain shape, without the effect that moves it. +#[test] +fn a_clipping_layer_with_preview_off_counts_its_plain_clipping_path() { + set_clip_region(moved_clip_region); + let mut d = Document::new(300.0, 200.0); + let top = d.layers[0].id; + let (mut mask, art) = half_clipped(&mut d); + mask.appearance.effects.push(crate::Effect { id: "test.move".into(), params: serde_json::json!({}), visible: true }); + let art_id = art.id; + let mut sublayer = (*d.layers[0]).clone(); + sublayer.id = d.alloc_id(); + if let NodeKind::Layer { children, clip, preview, .. } = &mut sublayer.kind { + (*children, *clip, *preview) = (vec![Arc::new(mask), Arc::new(art)], true, false); + } + d.insert(Some(top), 0, sublayer).unwrap(); + // The art's top edge, within the clipping path's own shape. + let p = Point::new(50.0, 0.5); + assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art_id)); + assert_eq!(objects_at(&d, p, HitOptions::default(), None).first(), Some(&art_id)); + assert!(marquee(&d, Rect::new(45.0, -5.0, 55.0, 5.0), None, true).contains(&art_id)); +} + +/// The pointer moving over empty canvas past more clip groups with live effects than the regions +/// kept measures each group's box once: the boxes are kept for every clip group. +#[test] +fn hovers_past_many_clip_groups_evaluate_each_clipping_path_once() { + set_clip_region(moved_clip_region); + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + for i in 0..(2 * CLIPS_KEPT) { + let x = 20.0 * i as f64; + let mut mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()); + mask.appearance.effects.push(crate::Effect { id: "test.move".into(), params: serde_json::json!({}), visible: true }); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 10.0)), Appearance::default_art()); + let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + } + let evaluated = || CLIPS_EVALUATED.with(std::cell::Cell::get); + let empty = Point::new(500.0, 500.0); + assert!(hit_test(&d, empty, HitOptions::default()).is_none()); + let first = evaluated(); + for _ in 0..3 { + assert!(hit_test(&d, empty, HitOptions::default()).is_none()); + } + assert_eq!(evaluated(), first); +} + +/// With more clip groups with live effects than the boxes kept, a new box replaces only as many +/// others as it needs room for: the next hover finds most of the boxes and evaluates few regions. +#[test] +fn hovers_past_more_clip_groups_than_the_boxes_kept_evaluate_few_again() { + set_clip_region(moved_clip_region); + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let groups = CLIP_BOXES_KEPT + 64; + let mut members = Vec::with_capacity(groups); + for i in 0..groups { + let x = (i % 100) as f64 * 5.0; + let y = (i / 100) as f64 * 5.0; + let mut mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 2.0, y + 2.0)), Appearance::default()); + mask.appearance.effects.push(test_move()); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 4.0, y + 2.0)), Appearance::default_art()); + let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + members.push(Arc::new(clip)); + } + if let Some(children) = d.node_mut(l).and_then(|n| n.children_mut()) { + *children = members; + } + let evaluated = || CLIPS_EVALUATED.with(std::cell::Cell::get); + let empty = Point::new(900.0, 900.0); + assert!(hit_test(&d, empty, HitOptions::default()).is_none()); + let first = evaluated(); + assert!(hit_test(&d, empty, HitOptions::default()).is_none()); + let again = evaluated() - first; + assert!(again < groups / 8, "{again} of {groups} regions evaluated again"); +} + +/// Every test of a clip group by its clipping path follows the region a live effect moved it to: +/// the selected art hidden by the mask, a click into a group holding the clip group, the locked +/// objects under the pointer, and, below a clipping layer whose Preview is on, art on a sublayer +/// whose Preview is off, which is clicked in outline while the layer above clips by its region. +#[test] +fn every_clip_test_follows_the_moved_region() { + set_clip_region(moved_clip_region); + let (moved, own) = (Point::new(150.0, 50.0), Point::new(50.0, 50.0)); + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mut mask, art) = half_clipped(&mut d); + mask.appearance.effects.push(test_move()); + let (g, art_id) = (d.alloc_id(), art.id); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + let outer = Node::group(d.alloc_id(), vec![Arc::new(clip)]); + d.insert(Some(l), 0, outer.clone()).unwrap(); + let opt = HitOptions::default(); + assert_eq!(selected_hidden_at(&d, own, opt, &[art_id]), Some(art_id), "hidden at the clipping path's own place"); + assert_eq!(selected_hidden_at(&d, moved, opt, &[art_id]), None, "shown in the moved region"); + let symbols = d.symbol_boxes(); + assert_eq!(hit_any(&outer, moved, opt, &symbols), Some(HitKind::Fill)); + assert_eq!(hit_any(&outer, own, opt, &symbols), None); + d.node_mut(art_id).unwrap().locked = true; + assert_eq!(locked_objects_at(&d, moved, opt), [art_id]); + assert!(locked_objects_at(&d, own, opt).is_empty()); + + // A clipping layer holding the mask and a sublayer whose Preview is off, with the art. + let mut d = Document::new(300.0, 200.0); + let top = d.layers[0].id; + let (mut mask, art) = half_clipped(&mut d); + mask.appearance.effects.push(test_move()); + let art_id = art.id; + let mut sublayer = (*d.layers[0]).clone(); + sublayer.id = d.alloc_id(); + if let NodeKind::Layer { children, preview, .. } = &mut sublayer.kind { + (*children, *preview) = (vec![Arc::new(art)], false); + } + d.insert(Some(top), 0, sublayer).unwrap(); + d.insert(Some(top), 0, mask).unwrap(); + d.node_mut(top).unwrap().set_clips(true); + // The art's top edge, at the clipping path's own place and in the moved region. + let (own, moved) = (Point::new(50.0, 0.5), Point::new(150.0, 0.5)); + assert!(hit_test(&d, own, opt).is_none()); + assert_eq!(hit_test(&d, moved, opt).map(|h| h.leaf), Some(art_id)); + assert_eq!(selected_hidden_at(&d, own, opt, &[art_id]), Some(art_id)); + assert_eq!(selected_hidden_at(&d, moved, opt, &[art_id]), None); +} + /// The art a mask hides counts only for a selected object: there a click hits nothing, and the /// selection tools grab the selection by that art. Not where the art shows (the click hits it), /// beyond the art, for the clip group selected as a whole, nor for art a click can't hit (locked, diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index fb6e8de8d9..9d34150a88 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -211,7 +211,9 @@ impl Node { /// A quick box round everything the object can paint, for culling clicks: paths and compound /// paths grow their geometric bounds by the farthest their strokes can reach from any path - /// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in + /// ([`Appearance::outset`]) instead of measuring along their shape. A clip group's or a + /// clipping layer's box takes in the region that the live geometry effects of its clipping + /// path give it ([`crate::hit::set_clip_region`]). An image's box takes in /// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers /// without a clipping mask and Intertwine groups take in their members' boxes, grown by their /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box @@ -261,6 +263,11 @@ impl Node { NodeKind::Blend { children, spec } => { vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols)) } + // The live geometry effects of a clipping path can move or grow the region it clips to + // past its plain shape ([`crate::hit::set_clip_region`]). + NodeKind::Layer { children, clip: true, .. } | NodeKind::Group { children, clip: true } => { + vectorcraft_geom::union_opt(self.visual(symbols), children.first().and_then(|c| crate::hit::reshaped_clip_box(c))) + } _ => self.visual(symbols), } } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a87b47be0e..a1a3fe5293 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -24,6 +24,8 @@ impl Session { }); // Clicks find an image's own fills and strokes where their effects draw them. vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); + // Clicks inside a clip group fall in the region its clipping path's effects give it. + vectorcraft_doc::hit::set_clip_region(reshaped_clip_region); // Clicks find a blend's steps and an envelope's distorted art where the canvas draws them, // evaluated again when the fonts, the hyphenation exceptions or the plug-ins change. vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || { @@ -491,6 +493,17 @@ impl Session { } } +/// The region clipping path `clip` clips to with its live geometry effects applied +/// ([`vectorcraft_render::effects::clip_outline`]), for clicks inside its clip group; `None` when +/// nothing in it has a visible geometry effect of its own or on a fill or stroke, so that clicks +/// count its plain shapes, type by its frame. +fn reshaped_clip_region(clip: &Node) -> Option> { + use vectorcraft_render::effects::has_geometry; + let mut reshaped = false; + clip.walk(&mut |n| reshaped |= has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(i.effects()))); + reshaped.then(|| vectorcraft_render::effects::clip_outline(clip)) +} + /// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased. fn sets_in_any(t: &TextObject, names: &HashSet) -> bool { !names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase())) diff --git a/crates/engine/src/tests/clip.rs b/crates/engine/src/tests/clip.rs index cbba2f36cc..7d500f6835 100644 --- a/crates/engine/src/tests/clip.rs +++ b/crates/engine/src/tests/clip.rs @@ -119,12 +119,54 @@ fn text_on_top_clips_by_its_glyphs() { assert_eq!(hit(&s, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0), Some(art)); assert!(b.x1 < 95.0, "{b:?}"); assert_eq!(hit(&s, b.x1 + 3.0, (b.y0 + b.y1) / 2.0), None, "beside the text"); + // An effect that does not reshape it, such as a drop shadow, leaves clicks on its frame. + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {}, "ids": [t.0], "item": null})).unwrap(); + assert_eq!(hit(&s, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0), Some(art), "in the counter, with a drop shadow"); // Release gives the text back. select(&mut s, &[g]); s.execute("object.clippingMask.release", &json!({})).unwrap(); assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_))); } +#[test] +fn clicks_follow_the_region_a_clipping_paths_effects_give_it() { + // Art from 10 to 90 pt, clipped by the artboard's left half, which a Transform effect moves + // 50 pt right: the art shows on the right half, as clicks find it. + let mut s = session(); + let art = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()); + let clip = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()); + select(&mut s, &[art, clip]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 50}, "ids": [clip.0], "item": null})).unwrap(); + let r = render(&s); + assert!(opaque(&r, 75, 50) && !opaque(&r, 25, 50), "the canvas clips by the moved region"); + assert_eq!(hit(&s, 75.0, 50.0), Some(art), "where the effect moved the clipping path"); + assert_eq!(hit(&s, 25.0, 50.0), None, "the clipping path's own place"); + let d = &s.doc().unwrap().doc; + let at = |x: f64| vectorcraft_doc::hit::objects_at(d, vectorcraft_geom::Point::new(x, 50.0), Default::default(), None); + assert_eq!(at(75.0), [g]); + assert!(at(25.0).is_empty()); + // A marquee across the art's edge reaches it within the moved region only. + let marquee = |x0: f64, x1: f64| vectorcraft_doc::hit::marquee(d, vectorcraft_geom::Rect::new(x0, 40.0, x1, 60.0), None, true); + assert_eq!(marquee(85.0, 95.0), [art], "the right edge, in the moved region"); + assert!(marquee(5.0, 15.0).is_empty(), "the left edge, in the clipping path's own place"); + + // The same with a sublayer that clips by its first object. + let mut d = (**d).clone(); + let members: Vec<_> = d.node(g).unwrap().children().unwrap().to_vec(); + let top = d.layers[0].id; + d.remove(g).unwrap(); + let mut sublayer = (*d.layers[0]).clone(); + sublayer.id = d.alloc_id(); + if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind { + (*children, *clip) = (members, true); + } + d.insert(Some(top), 0, sublayer).unwrap(); + let leaf = |x: f64| vectorcraft_doc::hit::hit_test(&d, vectorcraft_geom::Point::new(x, 50.0), Default::default()).map(|h| h.leaf); + assert_eq!(leaf(75.0), Some(art)); + assert_eq!(leaf(25.0), None); +} + #[test] fn make_refuses_a_top_object_that_cannot_clip() { let mut s = session(); diff --git a/crates/tools/src/addons/enclosing_lasso.rs b/crates/tools/src/addons/enclosing_lasso.rs index 9b92f0833d..0f674aead9 100644 --- a/crates/tools/src/addons/enclosing_lasso.rs +++ b/crates/tools/src/addons/enclosing_lasso.rs @@ -134,7 +134,7 @@ impl Tool for EnclosingLassoTool { let symbols = cx.doc.symbol_boxes(); let region = (points.len() >= 3).then(|| Region::new(polygon(&points, true))).flatten(); let mut ids = if let Some(region) = region { - enclosure_candidates(cx.doc, region.path.bounding_box(), cx.isolation) + enclosure_candidates(cx.doc, region.path.bounding_box(), cx.isolation, cx.outline) .into_iter() .filter(|id| cx.doc.node(*id).is_some_and(|n| node_inside(n, &symbols, ®ion))) .collect() diff --git a/crates/tools/src/addons/enclosing_lasso/tests.rs b/crates/tools/src/addons/enclosing_lasso/tests.rs index 836e640306..a8ba79aac7 100644 --- a/crates/tools/src/addons/enclosing_lasso/tests.rs +++ b/crates/tools/src/addons/enclosing_lasso/tests.rs @@ -367,3 +367,25 @@ fn enclosing_lasso_considers_the_geometry_a_visible_compound_paints() { ); } } + +/// A loop around clipped art that the clipping path's own shape holds, but not the region a live +/// effect moved it to, selects the art in Outline view only, which draws the clipping path without +/// its effects. +#[test] +fn outline_view_loops_measure_clipped_art_by_the_plain_clipping_path() { + let (d, _, art) = doc_with_moved_clip_region(); + let s = Selection::default(); + let p = paint(); + let around = [(410.0, 410.0), (490.0, 410.0), (490.0, 490.0), (410.0, 490.0)]; + let gesture = |outline: bool| { + let cx = ToolContext { outline, ..cx(&d, &s, &p) }; + let mut tool = crate::create(ID); + tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, 410.0, 410.0)); + for &(x, y) in &around[1..] { + tool.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y)); + } + tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, 410.0, 490.0)) + }; + assert_eq!(gesture(true), vec![Action::Exec("select.set".into(), json!({"ids": [art.0]}))]); + assert_eq!(gesture(false), vec![Action::Exec("select.set".into(), json!({"ids": []}))]); +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 3030414b38..cee2c7dfa1 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1608,7 +1608,7 @@ impl DirectSelectionTool { // (members of groups too), each whole, as a click selects one; with an Alt-marquee, every // object it touches, each whole. if enclosed || whole { - let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true); + let mut touched = vectorcraft_doc::hit::marquee_in_view(cx.doc, r, cx.isolation, true, cx.outline); let editable = cx.doc.editable(); touched.retain(|id| editable(*id)); let picked = if enclosed { crate::select::inside(cx, r, touched) } else { touched }; @@ -1680,7 +1680,7 @@ impl DirectSelectionTool { } // Objects without anchors to pick (type, images, symbols…) are selected whole when // the marquee touches them, as with the Selection tool (#927). - for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) { + for id in vectorcraft_doc::hit::marquee_in_view(cx.doc, r, cx.isolation, true, cx.outline) { let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); if whole && !sel.iter().any(|(s, _)| *s == id) && !framed.iter().any(|(f, _)| *f == id) && !meshed.contains(&id) { sel.push((id, vec![])); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 7ce48bdc1e..32229158af 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -2396,3 +2396,26 @@ fn type_on_a_path_shows_and_drags_its_paths_handles() { assert!((v["x"].as_f64().unwrap() - 400.0).abs() < 1e-9, "Shift keeps it straight above its anchor: {v}"); assert!(handles(&ToolContext { handles_multiple: false, ..cx(&d, &s, &p) }).is_empty()); } + +/// An Alt-marquee across clipped art where the clipping path's own shape, but not the region a +/// live effect moved it to, lets it show selects the art in Outline view only, which draws the +/// clipping path without its effects. +#[test] +fn outline_view_marquees_measure_clipped_art_by_the_plain_clipping_path() { + let (d, _, art) = doc_with_moved_clip_region(); + let s = Selection::default(); + let p = paint(); + let alt = Mods { alt: true, ..Mods::default() }; + let marquee = |outline: bool| { + let c = ToolContext { outline, ..cx(&d, &s, &p) }; + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 440.0, 410.0).with_mods(alt)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 460.0, 430.0).with_mods(alt)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 460.0, 430.0).with_mods(alt)) + }; + let selects = |a: &[Action]| a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([art.0]))); + let a = marquee(true); + assert!(selects(&a), "outline: {a:?}"); + let a = marquee(false); + assert!(!selects(&a), "preview: {a:?}"); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 1119bb0764..74e11e4760 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -450,6 +450,36 @@ pub(crate) mod testutil { (d, g, clip, art) } + /// A document with the rectangle of [`doc_with_rect`] and a clip group whose clipping path, + /// (400, 400)–(500, 500), has a live effect that moves the region it clips to 100 pt right + /// (registered with [`vectorcraft_doc::hit::set_clip_region`]), over art at (420, 420)–(480, 480), + /// inside the clipping path's own shape and outside the moved region: the document, the clip + /// group and the art. + pub fn doc_with_moved_clip_region() -> (Document, NodeId, NodeId) { + fn moved(clip: &Node) -> Option> { + let mut moves = false; + clip.walk(&mut |n| moves |= n.appearance.effects.iter().any(|e| e.visible && e.id == "test.move")); + if !moves { + return None; + } + Some(vectorcraft_doc::hit::clip_regions(clip).into_iter().next().map(|(mut bp, rule)| { + bp.apply_affine(vectorcraft_geom::Affine::translate((100.0, 0.0))); + (bp, rule) + })) + } + vectorcraft_doc::hit::set_clip_region(moved); + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mut path = Node::path(clip, shapes::rectangle(Rect::new(400.0, 400.0, 500.0, 500.0)), Default::default()); + path.appearance.effects.push(vectorcraft_doc::Effect { id: "test.move".into(), params: serde_json::json!({}), visible: true }); + let picture = Node::path(art, shapes::rectangle(Rect::new(420.0, 420.0, 480.0, 480.0)), Appearance::default_art()); + let group = + Node::new(g, vectorcraft_doc::NodeKind::Group { children: vec![std::sync::Arc::new(path), std::sync::Arc::new(picture)], clip: true }); + d.insert(Some(l), 1, group).unwrap(); + (d, g, art) + } + /// A new layer of `d`, above the others, with the unpainted rectangle `mask` as its clipping /// mask and `art` clipped by it: the layer and the clipping mask. pub fn add_masked_layer(d: &mut Document, mask: Rect, art: Node) -> (NodeId, NodeId) { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 70060ebc2b..be767f60c3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -40,7 +40,7 @@ use std::cell::RefCell; use std::collections::HashMap; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee, objects_at, selected_hidden_at}; +use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee_in_view, objects_at, selected_hidden_at}; use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; @@ -1125,7 +1125,7 @@ impl Tool for SelectionTool { if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) { return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; } - let touched = marquee(cx.doc, r, cx.isolation, false); + let touched = marquee_in_view(cx.doc, r, cx.isolation, false, cx.outline); let enclosed = marquee_enclosed(cx, enclosed, marquee_direction(cx, start, p, directional_inside)); let ids = if enclosed { inside(cx, r, touched) } else { touched }; let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) })); diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 63ca697c7d..a04741498d 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2558,3 +2558,22 @@ fn outline_view_click_through_an_image_selects_the_path_behind() { assert!(!selects(&a, back), "preview, {picked:?} selected: {a:?}"); } } + +/// A marquee across clipped art where the clipping path's own shape, but not the region a live +/// effect moved it to, lets it show reaches the clip group in Outline view only, which draws the +/// clipping path without its effects. +#[test] +fn outline_view_marquees_measure_a_clip_group_by_its_plain_clipping_path() { + let (d, g, _) = doc_with_moved_clip_region(); + let s = Selection::default(); + let p = paint(); + let marquee = |outline: bool| { + let cx = ToolContext { outline, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 440.0, 410.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 460.0, 430.0)); + t.pointer(&cx, &ev(PointerKind::Up, 460.0, 430.0)) + }; + assert_eq!(marquee(true), vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); + assert_eq!(marquee(false), vec![Action::Exec("select.set".into(), json!({"ids": []}))]); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4c07d6e597..2dcb038f04 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1582,6 +1582,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export, and type used as a clipping path keeps them when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. + Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. `doc::hit::tests` and the engine's `tests::clip` cover it. - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. From b562fc1c54b874d699ad66b143adc378bc37ca06 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 05:12:51 -0800 Subject: [PATCH 25/82] Text wrap: an out-of-range or non-number offset is refused object.textWrap.make and object.textWrap.options kept an offset outside -1000 to 1000 pt at the nearer end of the range, and read an offset that is not a number, or an invert that is not true or false, as if it had been left out. Both commands now return an error for such a value and leave the wrap options and the undo history unchanged. An omitted or null value still takes the default (6 pt and no invert for Make, the current options for Text Wrap Options), and Text Wrap Options with only null values reads the current options. A stored offset outside the range is kept when only Invert Wrap changes. The MCP text_wrap tool checks offset, invert and release before it changes the selection, so a refused call changes nothing, and a release that is not true or false is an error instead of making a wrap. --- crates/engine/src/cmd/textwrap.rs | 92 +++++++++++++++++++++++---- crates/mcp/src/tests.rs | 21 ++++++ crates/mcp/src/tools/definitions.rs | 2 +- crates/mcp/src/tools/dispatch.rs | 32 ++++++++-- crates/ui-egui/src/dialogs/command.rs | 9 ++- docs/mcp.md | 2 +- docs/target-app-parity.md | 1 + 7 files changed, 136 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index d5eefe9d4d..9ff48848af 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, NodeId, NodeKind, TextWrap}; use super::typecmd::refresh_bounds; -use super::{CommandSpec, EngineError, Result, Session, bool_or, checked_ids_or_selection, cmd, f64_or, has_selection}; +use super::{CommandSpec, EngineError, Result, Session, bad, checked_ids_or_selection, cmd, has_selection, number_or, switch_or}; pub fn specs() -> Vec { vec![ @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Text Wrap"], None, - "{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; a negative offset (to -1000) lets the text that far into them → {ids}", + "{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; offset -1000 to 1000 (a negative one lets the text that far into them); an offset outside that range or not a number, or an invert that is not true or false, is an error → {ids}", has_selection, make ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Text Wrap Options…", ["Object", "Text Wrap"], None, - "{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects (offset -1000 to 1000; a negative one lets text into the objects); no options → the current {offset, invert}", + "{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects (offset -1000 to 1000; a negative one lets text into the objects); an offset outside that range or not a number, or an invert that is not true or false, is an error; no options (or null ones) → the current {offset, invert}", has_selection, options ), @@ -37,14 +37,23 @@ pub fn specs() -> Vec { /// The Offset a wrap object takes, in points. pub const TEXT_WRAP_OFFSET_RANGE: std::ops::RangeInclusive = -1000.0..=1000.0; -fn wrap_param(p: &Value, base: TextWrap) -> TextWrap { - let (lo, hi) = (*TEXT_WRAP_OFFSET_RANGE.start(), *TEXT_WRAP_OFFSET_RANGE.end()); - TextWrap { offset: f64_or(p, "offset", base.offset).clamp(lo, hi), invert: bool_or(p, "invert", base.invert) } +/// The options `p` gives, with `base` for an omitted or null `offset` or `invert`. An `offset` that is +/// not a number or lies outside [`TEXT_WRAP_OFFSET_RANGE`], or an `invert` that is not true or false, +/// is an error. `base`'s offset is kept as it is. +fn wrap_param(p: &Value, base: TextWrap, cmd: &str) -> Result { + let key = "offset"; + let given = p.get(key).is_some_and(|v| !v.is_null()); + let offset = number_or(p, key, base.offset, cmd)?; + if given && !TEXT_WRAP_OFFSET_RANGE.contains(&offset) { + let (lo, hi) = (TEXT_WRAP_OFFSET_RANGE.start(), TEXT_WRAP_OFFSET_RANGE.end()); + return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))); + } + Ok(TextWrap { offset, invert: switch_or(p, "invert", base.invert, cmd)? }) } fn make(s: &mut Session, p: &Value) -> Result { let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?; - let w = wrap_param(p, TextWrap::default()); + let w = wrap_param(p, TextWrap::default(), "object.textWrap.make")?; s.edit("Make Text Wrap", |d, _| { for id in &ids { let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; @@ -86,10 +95,10 @@ fn options(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; ids.iter().find_map(|i| d.node(*i).and_then(|n| n.extras().wrap)).unwrap_or_default() }; - if p.get("offset").is_none() && p.get("invert").is_none() { + if ["offset", "invert"].iter().all(|k| p.get(k).is_none_or(Value::is_null)) { return Ok(json!({ "offset": current.offset, "invert": current.invert })); } - let w = wrap_param(p, current); + let w = wrap_param(p, current, "object.textWrap.options")?; s.edit("Text Wrap Options", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) @@ -212,11 +221,72 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(6.0)); assert!(beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s)); - // An offset past -1000 is kept at -1000: the whole object lets text through. - assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -5000})).unwrap()["offset"], json!(-1000.0)); + // An offset of -1000 lets the text through the whole object. + assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -1000})).unwrap()["offset"], json!(-1000.0)); assert!(lines(&s, t).iter().all(|l| l.0 < 1.0 && l.1 > 300.0)); } + /// An offset that is not a number or lies outside -1000 to 1000 pt, or an invert that is not + /// true or false, is an error for Make and Options, and leaves the options and the history as + /// they were. Omitted and null values take the default, and the range's ends are accepted. + #[test] + fn text_wrap_commands_refuse_bad_offsets_and_inverts_without_editing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let wrap = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let refused = [ + (json!({"offset": "12"}), "`offset` must be a number"), + (json!({"offset": true}), "`offset` must be a number"), + (json!({"offset": [12]}), "`offset` must be a number"), + (json!({"offset": {"pt": 12}}), "`offset` must be a number"), + (json!({"offset": 1000.5}), "`offset` must be from -1000 to 1000"), + (json!({"offset": -1000.5}), "`offset` must be from -1000 to 1000"), + (json!({"offset": 5000}), "`offset` must be from -1000 to 1000"), + (json!({"offset": 12, "invert": "true"}), "`invert` must be true or false"), + (json!({"offset": 12, "invert": 1}), "`invert` must be true or false"), + (json!({"invert": "false"}), "`invert` must be true or false"), + ]; + let start = undo(&s); + for (p, msg) in &refused { + let e = s.execute("object.textWrap.make", p).unwrap_err().to_string(); + assert!(e.contains(msg), "make {p}: {e}"); + assert!(wrap(&s).is_none(), "make {p} made a wrap"); + assert_eq!(undo(&s), start, "make {p} added history"); + } + // Omitted and null values: Make's 6 pt default. + s.execute("object.textWrap.make", &json!({"offset": null, "invert": null})).unwrap(); + assert_eq!(wrap(&s), Some(vectorcraft_doc::TextWrap { offset: 6.0, invert: false })); + s.execute("object.textWrap.options", &json!({"offset": 40, "invert": true})).unwrap(); + let before = undo(&s); + for (p, msg) in &refused { + let e = s.execute("object.textWrap.options", p).unwrap_err().to_string(); + assert!(e.contains(msg), "options {p}: {e}"); + assert_eq!(wrap(&s), Some(vectorcraft_doc::TextWrap { offset: 40.0, invert: true }), "options {p}"); + assert_eq!(undo(&s), before, "options {p} added history"); + } + // Null options read the current ones without an undo step; a null one keeps its value. + assert_eq!(s.execute("object.textWrap.options", &json!({"offset": null, "invert": null})).unwrap(), json!({"offset": 40.0, "invert": true})); + assert_eq!(undo(&s), before); + assert_eq!( + s.execute("object.textWrap.options", &json!({"offset": null, "invert": false})).unwrap(), + json!({"offset": 40.0, "invert": false}) + ); + // The range's ends are accepted by both commands. + for (command, offset) in + [("object.textWrap.options", 1000), ("object.textWrap.options", -1000), ("object.textWrap.make", 1000), ("object.textWrap.make", -1000)] + { + s.execute(command, &json!({ "offset": offset })).unwrap(); + assert_eq!(wrap(&s).unwrap().offset, f64::from(offset), "{command} {offset}"); + } + // A stored offset outside the range (from a file) is kept when only Invert Wrap changes. + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.node_mut(NodeId(id)).unwrap().extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset: 5000.0, invert: false }); + assert_eq!(s.execute("object.textWrap.options", &json!({"invert": true})).unwrap(), json!({"offset": 5000.0, "invert": true})); + } + #[test] fn text_flows_on_both_sides_of_a_centred_object() { let mut s = Session::new(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6529cf175d..4de488f22a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1597,6 +1597,27 @@ fn text_effect_pathfinder_transform_graph_and_wrap_tools() { let ds = d.to_string(); // The wrap object carries the options and the area type resolved its shape. assert!(ds.contains("\"wrap\":{\"invert\":false,\"offset\":8.0}") && ds.contains("\"wrap\":[{\"invert\":false,\"offset\":8.0"), "{area}"); + // An offset outside -1000 to 1000 pt, an invert or a release that is not true or false is an + // error, with release too, and the wrap and the selection stay as they were. + let r = call(&mut s, 20, "run_command", json!({"command": "select.set", "params": {"ids": [g]}})); + assert_eq!(r["isError"], false, "{r}"); + for (id, args) in [ + (21, json!({"ids": [c], "offset": 5000})), + (22, json!({"ids": [c], "offset": "8"})), + (23, json!({"ids": [c], "invert": "true"})), + (24, json!({"ids": [c], "release": "true"})), + (25, json!({"ids": [c], "release": true, "offset": -5000})), + ] { + let r = call(&mut s, id, "text_wrap", args.clone()); + assert_eq!(r["isError"], true, "{args}: {r}"); + assert!(doc_json(&mut s).to_string().contains("\"wrap\":{\"invert\":false,\"offset\":8.0}"), "{args}"); + } + // The graph is still the selection: a wrap made without ids goes on it, not on the circle. + let r = call(&mut s, 26, "text_wrap", json!({"offset": 9})); + assert_eq!(r["isError"], false, "{r}"); + let made: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(made["ids"], json!([g]), "{r}"); + call(&mut s, 27, "text_wrap", json!({"release": true})); call(&mut s, 15, "text_wrap", json!({"ids": [c], "release": true})); assert!(!doc_json(&mut s).to_string().contains("\"offset\":8.0")); } diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index e583753e4d..e7ba79df7c 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -507,7 +507,7 @@ pub fn tool_definitions() -> Vec { tool( "text_wrap", "Text wrap", - "Make the selected (or `ids`) objects wrap objects: area type below them in the same layer flows around them (offset in pt, invert = flow inside). `release: true` removes the wrap.", + "Make the selected (or `ids`) objects wrap objects: area type below them in the same layer flows around them (offset in pt, invert = flow inside). `release: true` removes the wrap. An offset outside -1000 to 1000 or not a number, or an invert or release that is not true or false, is an error and changes nothing.", obj( json!({ "ids": {"type": "array", "items": {"type": "integer"}}, diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index b1c5af70bb..57f6e6e63a 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -19,6 +19,29 @@ pub(super) const MAX_SIDE_WINDOW: &str = /// The `traceViews` argument of the artboard renders. pub(super) const TRACE_VIEWS: &str = "Draw Image Trace objects in the view set with imageTrace.setView (outlines, the source image…), as the app's window does; by default they draw their tracing result, as exports do"; +/// The `text_wrap` tool's `release` and its `offset` and `invert` for `object.textWrap.make`, checked +/// before the tool changes the selection: an omitted or null value is left out, an `offset` that is +/// not a number or lies outside [`vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE`] is an error, and so is +/// an `invert` or `release` that is not true or false. +fn text_wrap_args(a: &Args) -> Result<(bool, Value), String> { + let mut p = json!({}); + for k in ["offset", "invert", "release"] { + let Some(v) = a.get(k).filter(|v| !v.is_null()) else { continue }; + if k == "offset" { + let range = vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE; + let n = v.as_f64().ok_or_else(|| format!("`{k}` must be a number, not {v}"))?; + if !range.contains(&n) { + return Err(format!("`{k}` must be from {} to {}", range.start(), range.end())); + } + } else if !v.is_boolean() { + return Err(format!("`{k}` must be true or false")); + } + p[k] = v.clone(); + } + let release = p.as_object_mut().and_then(|m| m.remove("release")).is_some_and(|v| v == true); + Ok((release, p)) +} + /// `traceViews`: false when absent. pub(super) fn trace_views(a: &Args) -> Result { match a.get("traceViews") { @@ -260,16 +283,11 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { + let (release, p) = text_wrap_args(a)?; select_ids(b, a)?; - if a.get("release").and_then(Value::as_bool) == Some(true) { + if release { return j(exec(b, "object.textWrap.release", json!({}))?); } - let mut p = json!({}); - for k in ["offset", "invert"] { - if let Some(v) = a.get(k) { - p[k] = v.clone(); - } - } j(exec(b, "object.textWrap.make", p)?) } "undo" => j(exec(b, "edit.undo", json!({}))?), diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 6656b45bf5..a42c255736 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -517,7 +517,7 @@ mod tests { } /// Text Wrap Options › Offset is a distance, and its range is the one `object.textWrap.options` - /// keeps an Offset to. + /// accepts: the command takes the range's ends and refuses an Offset outside them. #[test] fn text_wrap_offset_is_a_distance_within_the_commands_range() { assert!(lengths("object.textWrap.options").contains(&"offset")); @@ -531,8 +531,11 @@ mod tests { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); s.execute("select.all", &json!({})).unwrap(); s.execute("object.textWrap.make", &json!({})).unwrap(); - for (sent, end) in [(5000.0, *range.end()), (-5000.0, *range.start())] { - assert_eq!(s.execute("object.textWrap.options", &json!({ "offset": sent })).unwrap()["offset"], json!(end)); + for end in [*range.end(), *range.start()] { + assert_eq!(s.execute("object.textWrap.options", &json!({ "offset": end })).unwrap()["offset"], json!(end)); + } + for sent in [5000.0, -5000.0] { + assert!(s.execute("object.textWrap.options", &json!({ "offset": sent })).is_err(), "{sent}"); } } diff --git a/docs/mcp.md b/docs/mcp.md index eb60457600..fadff014d9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -660,7 +660,7 @@ sampler. | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | | `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. | +| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. `offset` is in points, from -1000 to 1000 (default 6). An offset outside that range or not a number, or an `invert` or `release` that is not true or false, is an error and changes nothing; `object.textWrap.make` and `object.textWrap.options` refuse the same values. | | `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. | For example, create editable Japanese area type with the engine's vertical writing mode: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad7e9f4eab..b9bf9dc01c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1324,6 +1324,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. When the frame cannot hold all of such a paragraph, the lines it holds are still composed with the whole paragraph, the lines past the frame's end measured as wide as the last line the frame holds (VectorCraft's own rule); they overflow or go on in the next threaded frame, which composes them again. Such a paragraph is broken with the Single-line Composer when the frame has no room for its first line 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), and for text that flows on between threaded objects, [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.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`). + `object.textWrap.make`, `object.textWrap.options` and the MCP `text_wrap` tool refuse an Offset outside -1000 to 1000 pt, VectorCraft's own range, or one that is not a number, and an Invert Wrap value that is not true or false, with an error and no change or undo step; an omitted or null value takes the default (`cmd::textwrap::tests::text_wrap_commands_refuse_bad_offsets_and_inverts_without_editing`). 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). From e086cf6d83c109e4c56b2e801f817f7540924ccc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 05:51:21 -0800 Subject: [PATCH 26/82] PDF, EPS and print: brush art painting onto the page keeps its path --- crates/brush/src/lib.rs | 27 +++++++++++++ crates/eps/src/scene.rs | 5 ++- crates/eps/src/tests.rs | 53 +++++++++++++++++++++++++ crates/pdf/src/export/nodes.rs | 13 +++++-- crates/pdf/src/print/layout.rs | 12 ++++-- crates/pdf/src/tests.rs | 71 ++++++++++++++++++++++++++++++++++ crates/pdf/src/tests/print.rs | 56 +++++++++++++++++++++++++++ crates/render/src/brush_fx.rs | 8 ++-- docs/mcp.md | 8 +++- docs/target-app-parity.md | 1 + 10 files changed, 240 insertions(+), 14 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 851afc9e7a..c3a532d54b 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -1001,6 +1001,33 @@ pub fn has_brush(n: &Node) -> bool { n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && s.visible)) } +/// How far brush art can paint past the visual box of `n`: `16 × w + 8`, where `w` is the widest +/// stroke of `n` (at least 1), when `n` has a visible brushed stroke with a paint; 0 when it has +/// none, as a brushed stroke without a paint draws no brush art. This is the room the renderer and +/// the exports leave for brush art when they cull. +pub fn reach(n: &Node) -> f64 { + let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.brush.is_some() && !s.paint.is_none()); + if !n.appearance.items.iter().any(paints) { + return 0.0; + } + let w = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max); + 16.0 * w + 8.0 +} + +/// The largest [`reach`] of `n` and of the objects in it: the members of its groups, layers and +/// live objects. Of a group or layer with a clipping path only the clipping path counts, as the +/// clip keeps its members' brush art inside it. A compound path counts its own strokes only, as +/// it is painted with its own appearance and not its paths'. Symbol art is not looked into +/// ([`vectorcraft_doc::SymbolBoxes::with_brush_art`] measures it). +pub fn reach_within(n: &Node) -> f64 { + let members = match &n.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => children.get(..1).unwrap_or_default(), + NodeKind::Compound { .. } => &[], + _ => n.children().map_or(&[][..], |c| &c[..]), + }; + members.iter().map(|c| reach_within(c)).fold(reach(n), f64::max) +} + /// Expand a brushed path: a group of [the path with its fills and unbrushed strokes (if any /// paint remains), the brush art…]. Ids are 0 (the caller re-ids). `None` if not brushed. pub fn expand(doc: &Document, n: &Node) -> Option { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 299d130f7d..8d147cb2d1 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -489,8 +489,11 @@ impl<'a> Scene<'a> { self.warn(TOO_LARGE); return; } + // Off the region, an object is still written when brush art painted along its brushed + // strokes, or those of the objects in it, can reach the region. + let off = |b: Rect, reach: f64| !overlaps(b.inflate(1.0 + reach, 1.0 + reach), self.o.region); match n.visual_bounds_with(&self.symbols) { - Some(b) if !force && !overlaps(b.inflate(1.0, 1.0), self.o.region) => return, + Some(b) if !force && off(b, 0.0) && off(b, vectorcraft_brush::reach_within(n)) => return, None if !n.is_container() => return, _ => {} } diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 8547c4d015..c8a4bee003 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1581,6 +1581,59 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() { assert!(page.contains("1 0 0 rg"), "{page}"); } +#[test] +fn a_path_whose_brush_art_reaches_the_region_is_written() { + // A line from (-5, 40) to (-5, 60) with a 1 pt red stroke painted by a 20 pt round nib lies + // left of the artboard, and the nib paints 10 pt either side of it, onto the artboard: alone, in + // a group and as a compound path. Without its brush, or 100 pt left of the artboard, where the + // nib does not reach it, the line is left out. + let red = |x: f64, brushed: bool| { + let mut n = Node::path( + vectorcraft_doc::NodeId(0), + shapes::line((x, 40.0).into(), (x, 60.0).into()), + Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0), + ); + if brushed { + n.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + } + n + }; + let cases: [(&str, f64, bool, bool); 5] = [ + ("path", -5.0, true, true), + ("group", -5.0, true, true), + ("compound path", -5.0, true, true), + ("unbrushed path", -5.0, false, false), + ("far path", -100.0, true, false), + ]; + for (what, x, brushed, written) in cases { + let mut d = Document::new(200.0, 200.0); + let mut lib = vectorcraft_brush::library(&d); + let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else { + panic!("the 3 pt. Round brush") + }; + c.size = 20.0; + vectorcraft_brush::store(&mut d, &lib); + let line = red(x, brushed); + let mut n = match what { + "group" => Node::group(vectorcraft_doc::NodeId(0), vec![Arc::new(line)]), + "compound path" => { + let mut c = Node::new( + vectorcraft_doc::NodeId(0), + NodeKind::Compound { children: vec![Arc::new(red(x, false))], rule: vectorcraft_geom::FillRule::NonZero }, + ); + c.appearance = line.appearance; + c + } + _ => line, + }; + n.id = d.alloc_id(); + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, n).unwrap(); + let page = page_of(&d, &opts(&d)); + assert_eq!(page.contains("1 0 0 rg") || page.contains("1 0 0 RG"), written, "{what}: {page}"); + } +} + #[test] fn level_preview_and_overprint_ids_read_back() { for l in Level::ALL { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 78930c7500..403cbc5431 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -14,6 +14,11 @@ struct Measured { members: Vec, } +/// Box `b` grown by `reach`, the room brush art can paint past it; `b` itself when `reach` is 0. +fn brushed(b: Option, reach: f64) -> Option { + if reach > 0.0 { b.map(|b| b.inflate(reach, reach)) } else { b } +} + #[cfg(test)] std::thread_local! { /// The objects this thread has measured for the walk and for the links (tests). @@ -312,15 +317,17 @@ impl Exporter<'_> { krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) } - /// The box of `n`, and of the members of a plain group or layer. + /// The box of `n`, and of the members of a plain group or layer. A box grows by the room brush + /// art can paint past the brushed strokes of `n` and of the objects in it + /// ([`vectorcraft_brush::reach_within`]), as the renderer culls it. fn measure(&self, n: &Node) -> Measured { if !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { #[cfg(test)] MEASURES.with(|m| m.set(m.get() + 1)); - return Measured { bounds: n.visual_bounds_with(&self.symbols), members: vec![] }; + return Measured { bounds: brushed(n.visual_bounds_with(&self.symbols), vectorcraft_brush::reach_within(n)), members: vec![] }; } let members: Vec<_> = n.editing_children().iter().map(|c| self.measure(c)).collect(); - Measured { bounds: n.visual_of_members(members.iter().map(|m| m.bounds)), members } + Measured { bounds: brushed(n.visual_of_members(members.iter().map(|m| m.bounds)), vectorcraft_brush::reach(n)), members } } pub(super) fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index a88dd89950..5d28e9e2b1 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -71,7 +71,9 @@ pub struct TileGrid { /// Calls `f` with the bounds of each object of `n` that prints: visible, not on a template /// layer and not a guide (layers are already picked by the Print Layers option). Symbol -/// instances are measured by their art from `symbols`. +/// instances are measured by their art from `symbols`. A box grows by the room brush art can +/// paint past the brushed strokes of the object and of the objects in it +/// ([`vectorcraft_brush::reach_within`]). fn printed(n: &Node, symbols: &SymbolBoxes, f: &mut impl FnMut(Rect)) { if !n.visible { return; @@ -85,7 +87,8 @@ fn printed(n: &Node, symbols: &SymbolBoxes, f: &mut impl FnMut(Rect)) { } _ => { if let Some(b) = n.visual_bounds_with(symbols) { - f(b); + let reach = vectorcraft_brush::reach_within(n); + f(if reach > 0.0 { b.inflate(reach, reach) } else { b }); } } } @@ -102,9 +105,10 @@ fn has_art(doc: &Document, r: Rect) -> bool { any } -/// The bounds of all the art that prints. +/// The bounds of all the art that prints, with the brush art it paints. The box of a symbol +/// instance whose art holds brushed strokes grows as in [`has_art`]. fn art_bounds(doc: &Document) -> Option { - let symbols = doc.symbol_boxes(); + let symbols = doc.symbol_boxes().with_brush_art(); let mut all: Option = None; for l in &doc.layers { printed(l, &symbols, &mut |b| all = Some(all.map_or(b, |a| a.union(b)))); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index bee80238bd..9487e1bca3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -246,6 +246,77 @@ 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()); } +/// A 200 × 100 pt document whose 3 pt. Round brush paints a 20 pt round nib. +fn doc_with_wide_nib() -> Document { + let mut d = doc(200.0, 100.0); + let mut lib = vectorcraft_brush::library(&d); + let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else { + panic!("the 3 pt. Round brush") + }; + c.size = 20.0; + vectorcraft_brush::store(&mut d, &lib); + d +} + +/// A line from (`x`, 40) to (`x`, 60) with a 1 pt black stroke, painted by the 3 pt. Round brush +/// when `brushed`. +fn nib_line(x: f64, brushed: bool) -> Node { + let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + if brushed { + stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + } + Node::path(NodeId(0), shapes::line((x, 40.0).into(), (x, 60.0).into()), stroke) +} + +#[test] +fn a_path_whose_brush_art_reaches_the_page_is_written() { + // The line at x = -5 lies left of the page, and the 20 pt nib paints 10 pt either side of it, + // onto the page: alone, in a group, in a knockout group and as a compound path. + let compound = |n: Node| { + let mut c = Node::new(NodeId(0), NodeKind::Compound { children: vec![Arc::new(nib_line(-5.0, false))], rule: FillRule::NonZero }); + c.appearance = n.appearance; + c + }; + let knockout = |n: Node| { + let mut g = Node::group(NodeId(0), vec![Arc::new(n), Arc::new(rect_node(Rect::new(150.0, 10.0, 170.0, 30.0), Color::rgb(0.0, 0.0, 1.0)))]); + g.knockout = vectorcraft_doc::Knockout::On; + g + }; + let group = |n: Node| Node::group(NodeId(0), vec![Arc::new(n)]); + let wraps: [(&str, &dyn Fn(Node) -> Node); 4] = + [("path", &|n| n), ("group", &group), ("knockout group", &knockout), ("compound path", &compound)]; + for (what, wrap) in wraps { + let mut d = doc_with_wide_nib(); + add(&mut d, wrap(nib_line(-5.0, true))); + let out = roundtrip(&d); + let nib: Vec = leaves(&out).iter().filter_map(|n| n.geometric_bounds()).filter(|b| b.x1 < 100.0).collect(); + assert_eq!(nib.len(), 1, "{what}: the nib's art, {nib:?}"); + assert!(close(nib[0], Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{what}: {nib:?}"); + } + // Without its brush, or 100 pt left of the page, where the nib does not reach it, the line is + // left out. + for line in [nib_line(-5.0, false), nib_line(-100.0, true)] { + let mut d = doc_with_wide_nib(); + add(&mut d, line); + assert!(leaves(&roundtrip(&d)).is_empty()); + } + // A clipping path left of the page keeps the nib's art off it. + let mut d = doc_with_wide_nib(); + let clip = Node::new( + NodeId(0), + NodeKind::Path { + path: shapes::rectangle(Rect::new(-30.0, 0.0, -2.0, 100.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + add(&mut d, Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(nib_line(-5.0, true))], clip: true })); + let out = roundtrip(&d); + assert!(leaves(&out).is_empty(), "{:?}", leaves(&out)); +} + /// 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..d7277153b3 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -451,6 +451,62 @@ 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 a_brushed_paths_brush_art_keeps_its_artboard_printed_and_framed() { + // A line from (-5, 40) to (-5, 60) with a 1 pt red stroke painted by a 20 pt round nib, alone, + // in a group or as the art of a symbol instance: the line lies left of the artboard, and the nib + // paints 10 pt either side of it, onto the artboard. + let line = || { + let mut stroke = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0); + stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + Node::path(vectorcraft_doc::NodeId(0), shapes::line((-5.0, 40.0).into(), (-5.0, 60.0).into()), stroke) + }; + for what in ["path", "group", "symbol instance"] { + let mut d = doc(100.0, 100.0, &[]); + let mut lib = vectorcraft_brush::library(&d); + let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else { + panic!("the 3 pt. Round brush") + }; + c.size = 20.0; + vectorcraft_brush::store(&mut d, &lib); + let mut n = match what { + "path" => line(), + "group" => Node::group(vectorcraft_doc::NodeId(0), vec![Arc::new(line())]), + _ => { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Nib".into(), art: Arc::new(line()) }); + Node::new(vectorcraft_doc::NodeId(0), NodeKind::SymbolInstance { symbol: "Nib".into(), xf: vectorcraft_geom::Affine::IDENTITY }) + } + }; + n.id = d.alloc_id(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n).unwrap(); + // Skip Blank Artboards keeps the artboard, and its page holds the nib's art. + let p = pages(&job(&d, json!({"skipBlank": true})).bytes); + assert_eq!(p.len(), 1, "{what}"); + assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{what}: {p:?}"); + // Ignoring artboards, the page holds all the nib's art, 20 × 40 pt. + let pv = preview(&d, &settings(json!({"artboards": "ignore"}))).unwrap(); + let t = pv.sheets[0].trim; + assert!(t[2] - t[0] >= 20.0 && t[3] - t[1] >= 40.0, "{what}: {t:?}"); + } + // A compound path is painted with its own appearance: the brushed stroke left on its path + // paints nothing, and the artboard stays blank. + let mut d = doc(100.0, 100.0, &[]); + let kind = NodeKind::Compound { children: vec![Arc::new(line())], rule: vectorcraft_geom::FillRule::NonZero }; + let mut compound = Node::new(d.alloc_id(), kind); + compound.appearance = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0).into(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, compound).unwrap(); + assert!(preview(&d, &settings(json!({"skipBlank": true}))).is_err()); + // A brushed stroke without a paint draws no brush art: the artboard stays blank. + let mut d = doc(100.0, 100.0, &[]); + let mut unpainted = line(); + unpainted.id = d.alloc_id(); + unpainted.appearance.stroke_mut().unwrap().paint = Paint::None; + d.insert(Some(layer), 0, unpainted).unwrap(); + assert!(preview(&d, &settings(json!({"skipBlank": true}))).is_err()); +} + #[test] fn copies_collate_and_reverse() { let mut d = diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 3c6ea922d4..fe7eb0f804 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -157,17 +157,17 @@ pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnO /// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it /// has a visible brushed stroke and of the objects in that art that have one /// ([`SymbolBoxes::brush_width`]) or the pieces live-effect plug-ins paint for them -/// ([`crate::fx::painted_brush_width`]), and 0 when neither has one. +/// ([`crate::fx::painted_brush_width`]), and 0 when neither has one. The room for `n`'s own strokes +/// is [`vectorcraft_brush::reach`], which the exports leave too. pub(crate) fn pad(symbols: &SymbolBoxes, n: &Node) -> f64 { - let own = vectorcraft_brush::has_brush(n) - .then(|| n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max)); + let own = vectorcraft_brush::reach(n); let art = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => { symbols.brush_width(symbol).into_iter().chain(crate::fx::painted_brush_width(symbols, symbol)).reduce(f64::max).map(|w| w.max(1.0)) } _ => None, }; - own.into_iter().chain(art).reduce(f64::max).map_or(0.0, |w| 16.0 * w + 8.0) + art.map_or(own, |w| own.max(16.0 * w + 8.0)) } pub use vectorcraft_brush::instance_art; diff --git a/docs/mcp.md b/docs/mcp.md index 0cbb30e0b6..2832e7f169 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5015,8 +5015,12 @@ when its symbol or a symbol nested in its art holds an instance of itself, and w nest 32 deep in its art. PDF and EPS export and the SVG of an artboard leave out art outside the page or artboard, and Skip Blank Artboards skips an artboard with no art on it. For both, the box of an instance whose art holds brushed strokes grows by 16 times the widest stroke (at least 1 pt) of the objects in its art that have a brushed stroke, -plus 8 pt. The SVG of an artboard also keeps an object whose effects paint onto the artboard, and an object that holds -an instance when the effects in the symbol's art, or the instance's own, paint onto it. +plus 8 pt. PDF and EPS export and Skip Blank Artboards grow the box of an object with a visible, painted brushed stroke the +same way, by its own widest stroke, and the box of an object that holds one by the largest such room among them (of a +clip group, only its clipping path counts). Print with Ignore Artboards frames the page around the art with the same +room for brushed objects and for instances whose art holds brushed strokes. The SVG of an artboard also keeps an +object whose effects paint onto the artboard, and an object that holds an instance when the effects in the symbol's +art, or the instance's own, paint onto it. ## Expand diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 08ce46cebf..f0349613f4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2003,6 +2003,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Later effects on every fill or stroke a painted effect returns (G17):** when a painted effect on a fill or stroke returns several fills or strokes, a later live-effect plug-in in that fill's or stroke's stack, and a later geometry effect on strokes, runs once on each of them in paint order, given its own paint and its own `_context.item` among them. Each run's pieces replace only that fill or stroke. A run that fails, is refused, is deferred or would pass the effects' budget leaves only its own fill or stroke as it was, and the others still run; a deferred stage is listed once, at the source fill's or stroke's place. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/effects/src/tests/painted.rs`. Scores and hours unchanged. - **Painted pieces with brushed strokes are culled and measured by their brush art (G17):** a piece that keeps the object's brushed stroke draws the brush along the piece's own path at the piece's stroke width. Culling leaves VectorCraft's usual room for brush art (16 × the stroke width + 8 pt) past each piece, from the piece's width rather than the object's, and so does culling of a group, layer, symbol instance, blend, envelope or repeat that holds the object. A group or layer also leaves that room for each brushed path among its members, painted or not. Object › Rasterize takes the brush art drawn along each piece and leaves out the object's own brush art, which is not drawn. Pieces without a brush are culled as before. Regressions: `crates/render/src/tests/paintedbrush.rs`. Scores and hours unchanged. +- **Brushed paths just off the page are exported and printed:** PDF and EPS export keep a path or compound path whose brush art reaches the page while the path lies just off it, alone or in a group, by leaving the canvas's room for brush art (16 × the widest stroke + 8 pt, VectorCraft's own value) past its box. Skip Blank Artboards keeps an artboard such brush art reaches, and Print with Ignore Artboards frames the page with that room around brushed paths and around instances whose art holds brushed strokes. A clip group leaves room only for its clipping path. Art without brushed strokes is written as before. Regressions: `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/print.rs`, `crates/eps/src/tests.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. - **Refreshing many deferred effects (G17):** Refresh Deferred Effects (`effect.refreshDeferred`) looks up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran in hash sets, so its time no longer grows with the square of the number of deferred stages. The deferred records are indexed by stage and input, so asking whether a stage stays deferred, replacing a stage's records when it runs, and ending a drawing after the first view look at that stage's records only, and the records keep the order they were made in. With 40,000 deferred stages, one refresh in a release build took 38 ms, down from 1,036 ms, and asking about and running each stage once took 16 ms, down from 2,287 ms (wall-clock time on a computer busy with other builds, one run each). Regressions: `crates/plugins/src/deferred.rs`. Scores and hours unchanged. - **Refresh Deferred Effects by object (G17):** `effect.refreshDeferred` takes `ids`, or `id` for one, naming objects in the layers or in the art of a symbol, pattern, brush or opacity mask, the objects `effect.deferredStatus` lists; an object without a deferred stage adds nothing to the counts, and an id of no object in the document is an error. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. From e45c6f518e295bca5d82619413948eb4fbbc0703 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 06:02:55 -0800 Subject: [PATCH 27/82] PDF export: PDF/X-1a writes tagged gray images in DeviceGray PDF/X-1a allows device colors only, but a gray TIFF that carried a gray ICC profile kept it when no color conversion applied, and the export then failed its PDF/X check. A gray or RGB PNG or JPEG with a profile went the same way through the PDF writer. In PDF/X-1a, images are now written without the profile they carry; the other standards and plain exports keep it. --- crates/pdf/src/export/images.rs | 28 +++++++++++++++++++--------- crates/pdf/src/export/text.rs | 2 +- crates/pdf/src/images.rs | 28 +++++++++++++++++++--------- crates/pdf/src/tests/compression.rs | 17 ++++++++++++++++- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index a44aa52526..1aaf3c3e7d 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -138,6 +138,7 @@ impl Exporter<'_> { } let doc = self.doc; let out = self.out.clone(); + let profiles = !out.standard.cmyk_only(); let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { let cmyk_image = blob.is_cmyk(); if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { @@ -146,12 +147,12 @@ impl Exporter<'_> { let r = if cmyk_image { crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None) } else { - crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) + crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles) }; if let Some(w) = r.warning { self.warn(w); } - r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit)) + r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)) }); if img.is_none() { self.warn(UNDECODED); @@ -318,10 +319,12 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { const MAX_EMBED_PIXELS: u64 = 1 << 27; /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats (a TIFF) decoded with the ICC profile they carry and their 16-bit samples -/// ([`crate::images::decoded_image`]). Without `sixteen_bit` (a file before PDF 1.5), a PNG or -/// another image of 16 bits a sample is written at 8 bits ([`png_8_bit`]). -pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { +/// other formats (a TIFF) decoded with their 16-bit samples and, when `profiles` allows it (not in +/// PDF/X-1a), the ICC profile they carry ([`crate::images::decoded_image`]). Without `profiles`, an +/// image of any format that carries a gray or RGB profile is decoded too, and written without it. +/// Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of 16 bits a sample is +/// written at 8 bits ([`png_8_bit`]). +pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. let size = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions(); @@ -329,7 +332,14 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee return None; } let bytes = blob.bytes.as_ref().clone(); - let direct = match blob.mime.as_str() { + // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, such an image is + // decoded and written without it. + let tagged = || { + let space = crate::images::source_icc(&bytes).and_then(|icc| vectorcraft_doc::cmyk::icc_color_space(&icc)); + matches!(space.as_ref(), Some(b"GRAY" | b"RGB ")) + }; + let mime = if !profiles && tagged() { "" } else { blob.mime.as_str() }; + let direct = match mime { "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), @@ -337,7 +347,7 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), _ => None, }; - direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit)) + direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit, profiles)) } #[cfg(test)] @@ -356,7 +366,7 @@ mod tests { let crc = !png[12..29].iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 })); png[29..33].copy_from_slice(&crc.to_be_bytes()); - assert!(super::embed(&ImageBlob::png(png), false, true).is_none()); + assert!(super::embed(&ImageBlob::png(png), false, true, true).is_none()); } #[test] diff --git a/crates/pdf/src/export/text.rs b/crates/pdf/src/export/text.rs index 4b0b3a047a..c9ef8203c7 100644 --- a/crates/pdf/src/export/text.rs +++ b/crates/pdf/src/export/text.rs @@ -325,7 +325,7 @@ impl Exporter<'_> { let img = match self.images.get(&cache) { Some(img) => img.clone(), None => { - let img = embed(&image.blob, self.interpolate, self.sixteen_bit); + let img = embed(&image.blob, self.interpolate, self.sixteen_bit, !self.out.standard.cmyk_only()); self.images.insert(cache, img.clone()); img } diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 85cb29f503..b32f1788be 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -78,7 +78,7 @@ fn icc_for(icc: Option>, space: ImageColorspace) -> Option>> /// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, /// which reads tags of any size), another format's as its decoder reads it. `None` without one. -fn source_icc(bytes: &[u8]) -> Option> { +pub(crate) fn source_icc(bytes: &[u8]) -> Option> { let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; if reader.format() == Some(ImageFormat::Tiff) { return vectorcraft_doc::cmyk::tiff_icc_profile(bytes); @@ -140,10 +140,11 @@ pub(crate) fn from_decoded(img: DynamicImage, icc: Option>, interpolate: Image::from_custom(raw, interpolate).ok() } -/// Image `bytes` decoded within [`limits`] with the ICC profile it carries ([`source_icc`]), as -/// [`from_decoded`] writes it. `None` when it can't be decoded. -pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool) -> Option { - from_decoded(decoded(bytes)?, source_icc(bytes), interpolate, sixteen_bit) +/// Image `bytes` decoded within [`limits`], with the ICC profile it carries ([`source_icc`]) when +/// `profiles` allows it (not in PDF/X-1a, which has device colours only), as [`from_decoded`] +/// writes it. `None` when it can't be decoded. +pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { + from_decoded(decoded(bytes)?, profiles.then(|| source_icc(bytes)).flatten(), interpolate, sixteen_bit) } /// Are all of the RGBA pixels `rgba` grey? @@ -304,9 +305,17 @@ fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { /// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask: /// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours -/// converted by `convert` (colour images). `interpolate` lets viewers smooth it. An image that -/// can't be decoded is kept as it is. -pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool, convert: Option<&Pixels>) -> Recoded { +/// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile +/// they carry when `profiles` allows it (not in PDF/X-1a, which has device colours only). +/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. +pub(crate) fn recode( + bytes: &[u8], + size: (f64, f64), + c: &CompressionSettings, + interpolate: bool, + convert: Option<&Pixels>, + profiles: bool, +) -> Recoded { let keep = Recoded { image: None, warning: None }; let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep }; let jpeg = reader.format() == Some(ImageFormat::Jpeg); @@ -338,7 +347,8 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in // Pixels that keep their colours keep the profile they are in; converted ones are in the // destination's. let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" }; - let icc = convert.is_none().then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)); + let icc = + (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)); let rgb_icc = icc.is_some() && kind == Kind::Color; let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8(); if kind == Kind::Mono { diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 8adf91879a..4d36e8aced 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -234,7 +234,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None); assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all"); let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap(); - let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None); + let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true); assert!(r.image.is_none(), "kept as it is"); } @@ -391,4 +391,19 @@ fn tagged_tiffs_keep_their_icc_profile() { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } + // PDF/X-1a has device colours only: a tagged gray TIFF or PNG is written in DeviceGray, kept as + // it is, resampled or compressed with JPEG. + let compressions = [json!({}), json!({"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"gray": {"compression": "jpeg"}})]; + let png = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png"); + for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png)] { + for compression in &compressions { + let mut settings: PdfSettings = + serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }); + let spaces = image_spaces(&r.unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")).bytes); + let gray = spaces.len() == 1 && spaces[0].space == "DeviceGray" && spaces[0].icc.is_none(); + assert!(gray, "PDF/X-1a, {format}, {compression}: {spaces:?}"); + } + } } From 9efde43f1d827b95b838b75cc5d310362a9814f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:20:33 -0800 Subject: [PATCH 28/82] Metafile import: EMF and WMF are read through craft-metafile, so EMF+ pictures open EMF and WMF import now plays the records with the shared craft-metafile reader (storytold/craft-libs#778, at the craft-libs rev the SVG, EPS and DXF crates already use) and maps its picture onto the document here: paths with their fills, strokes, caps, joins, miter limits and dashes, clipping groups, images and point type. The EMF and WMF record players in crates/metafile/src/import and the DIB decoder they used are removed. The writers are unchanged, and the PICT reader keeps its own player. What changes for a file being opened, placed or pasted, and for an EPS file's Windows metafile preview (read when its PostScript can't be): - An EMF that draws with EMF+ records is read from them. It opened empty, with a warning that EMF+ is not read. A file with both kinds of record is read from its EMF+ records too. - Hatched and pattern brushes fill with the average color of the brush's tile, translucent where the tile has gaps. Hatches filled with the hatch color and pattern brushes with gray. - Translucent fills, strokes and EMF+ text keep their alpha as opacity. - Text keeps a stretch across from the world transform; it kept only the turn. Text the file clips to a rectangle is clipped to it, and an underlined or struck-through font gives underlined or struck-through type. - A clip path whose bounding box holds the whole picture is kept unless it is that rectangle; a diamond or a bow tie around the picture was dropped. - Images drawn with SRCAND, SRCPAINT or SRCINVERT are drawn as they look over white, and images drawn with other raster operations are drawn as a plain copy. They were left out with a warning. - Excluded clip rectangles and the other clip region modes are applied. A clip path combined with RGN_OR, RGN_XOR or RGN_DIFF is ignored without the warning it gave. - An image past the reader's size or pixel limits, or one that can't be decoded, is left out without a warning of its own. A file past the reader's limits (more than 32 nested clip paths, 200,000 drawn items, 256 MiB held) is read up to that point without a warning. - An EMF record whose size is not a multiple of 4 ends the file, with the "ends early" warning. Tests: an EMF+ only picture imports its fill (it failed before), a dual EMF+ file draws its translucent EMF+ fill and not its fallback, hatch and pattern brush colors, clipped and underlined text, excluded clip rectangles, a shared clipping group, an odd record size, WMF at 1440 units an inch without a placeable header and at its own units with an inverted box, an EMF without a frame, diamond and bow-tie clips, text with a stretch, clips nested past the stack and 200,000 clip records; the fuzz test gains random EMF+ records. --- Cargo.lock | 12 + Cargo.toml | 2 + crates/engine/tests/import_fuzz.rs | 18 + crates/metafile/Cargo.toml | 3 +- crates/metafile/src/bytes.rs | 52 +-- crates/metafile/src/dib.rs | 288 +----------- crates/metafile/src/import.rs | 632 +++----------------------- crates/metafile/src/import/emf.rs | 392 ---------------- crates/metafile/src/import/pict.rs | 6 +- crates/metafile/src/import/picture.rs | 523 +++++++++++++++++++++ crates/metafile/src/import/wmf.rs | 338 -------------- crates/metafile/src/lib.rs | 13 +- crates/metafile/src/tests/import.rs | 304 ++++++++++++- docs/mcp.md | 9 +- docs/target-app-parity.md | 1 + 15 files changed, 948 insertions(+), 1645 deletions(-) delete mode 100644 crates/metafile/src/import/emf.rs create mode 100644 crates/metafile/src/import/picture.rs delete mode 100644 crates/metafile/src/import/wmf.rs diff --git a/Cargo.lock b/Cargo.lock index f3e85a08a7..32fcfc62ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1043,6 +1043,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-metafile" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" +dependencies = [ + "encoding_rs", + "image", + "kurbo 0.13.1", + "thiserror 2.0.21", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -6681,6 +6692,7 @@ dependencies = [ name = "vectorcraft-metafile" version = "0.24.1" dependencies = [ + "craft-metafile", "image", "kurbo 0.13.1", "vectorcraft-brush", diff --git a/Cargo.toml b/Cargo.toml index c7856d589e..610e2b462c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ 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" } +# WMF, EMF and EMF+ reader (craft-libs#778). +craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } 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/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 79178d6233..51615e70c8 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2933,6 +2933,24 @@ proptest! { survive_metafile("WMF records", "x.wmf", &wmf_of(&records))?; } + #[test] + fn random_emf_plus_records_never_panic(records in prop::collection::vec((0x4001u16..0x403B, any::(), arb_body()), 0..32)) { + // One comment of EMF+ records (type, flags, data) after an EmfPlusHeader. + let mut plus = vec![]; + let header = (0x4001u16, 0u16, [0xDBC0_1002u32, 0, 96, 96].iter().flat_map(|v| v.to_le_bytes()).collect::>()); + for (kind, flags, data) in std::iter::once(&header).chain(&records) { + plus.extend(kind.to_le_bytes()); + plus.extend(flags.to_le_bytes()); + plus.extend((12 + data.len() as u32).to_le_bytes()); + plus.extend((data.len() as u32).to_le_bytes()); + plus.extend(data); + } + let mut comment = (4 + plus.len() as u32).to_le_bytes().to_vec(); + comment.extend(b"EMF+"); + comment.extend(plus); + survive_metafile("EMF+ records", "x.emf", &emf_of(&[(70, comment)]))?; + } + #[test] fn pasted_metafiles_never_panic(which in 0usize..2, cut in 0usize..200_000, edits in prop::collection::vec((0usize..200_000, any::()), 0..12)) { paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?; diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index 01b5d6434a..9fdf99d732 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-metafile" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft metafiles: hand-written EMF and WMF writers and readers, and a QuickDraw picture (PICT) reader" +description = "VectorCraft metafiles: hand-written EMF and WMF writers, EMF, EMF+ and WMF import through craft-metafile, and a QuickDraw picture (PICT) reader" [lints] workspace = true @@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } vectorcraft-brush = { workspace = true } vectorcraft-text = { workspace = true } +craft-metafile = { workspace = true } kurbo = { workspace = true } image = { workspace = true } diff --git a/crates/metafile/src/bytes.rs b/crates/metafile/src/bytes.rs index ddf534b793..133a476866 100644 --- a/crates/metafile/src/bytes.rs +++ b/crates/metafile/src/bytes.rs @@ -1,5 +1,4 @@ -//! Little-endian values: the buffer both writers build records in, and a reader that never reads -//! past its data (every read is an `Option`). +//! Little-endian values: the buffer both writers build records in. /// Bytes being written. #[derive(Default)] @@ -45,52 +44,3 @@ impl Out { } } } - -/// Sequential little-endian reads from a slice. -#[derive(Clone, Copy)] -pub(crate) struct Reader<'a> { - data: &'a [u8], - pos: usize, -} - -impl<'a> Reader<'a> { - pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0 } - } - pub fn bytes(&mut self, n: usize) -> Option<&'a [u8]> { - let b = self.data.get(self.pos..self.pos.checked_add(n)?)?; - self.pos += n; - Some(b) - } - fn array(&mut self) -> Option<[u8; N]> { - self.bytes(N)?.try_into().ok() - } - pub fn u16(&mut self) -> Option { - self.array().map(u16::from_le_bytes) - } - pub fn i16(&mut self) -> Option { - self.array().map(i16::from_le_bytes) - } - pub fn u32(&mut self) -> Option { - self.array().map(u32::from_le_bytes) - } - pub fn i32(&mut self) -> Option { - self.array().map(i32::from_le_bytes) - } - pub fn f32(&mut self) -> Option { - self.array().map(f32::from_le_bytes) - } - pub fn skip(&mut self, n: usize) -> Option<()> { - self.bytes(n).map(drop) - } - /// The bytes not read yet. - pub fn rest(&self) -> &'a [u8] { - self.data.get(self.pos..).unwrap_or_default() - } -} - -/// `len` bytes of `data` from `offset` (record-relative offsets read from untrusted files). -pub(crate) fn slice(data: &[u8], offset: u32, len: u32) -> Option<&[u8]> { - let start = usize::try_from(offset).ok()?; - data.get(start..start.checked_add(usize::try_from(len).ok()?)?) -} diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs index 4aeb5b4381..9bc720f435 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -1,28 +1,22 @@ //! Device-independent bitmaps (DIBs), the pixels inside image records: written bottom-up as 24-bit -//! (opaque, composited over white) or 32-bit premultiplied BGRA (AlphaBlend's source); read from 1, -//! 4, 8, 16, 24 and 32-bit uncompressed and bit-field bitmaps and from embedded JPEG and PNG. +//! (opaque, composited over white) or 32-bit premultiplied BGRA (AlphaBlend's source). use std::io::Cursor; -use crate::bytes::Reader; - /// Most pixels along a side of an image the writers store (larger ones are scaled down). const MAX_SIDE: u32 = 8192; /// Most pixels an image the writers store holds. const MAX_PIXELS: u64 = 1 << 24; -/// Most pixels along a side, and in all, of a bitmap the reader decodes. +/// Most pixels along a side, and in all, of a bitmap the PICT reader decodes. pub(crate) const MAX_READ_SIDE: u32 = 1 << 15; pub(crate) const MAX_READ_PIXELS: u64 = 1 << 26; /// Most pixels the bitmaps of one file may decode to together (four of the largest one), so a small /// file of many image records can't cost minutes of decoding (#3600). pub(crate) const MAX_FILE_PIXELS: u64 = 1 << 28; -/// What [`decode`] says of a bitmap past what is left of the file's budget. +/// What [`charge`] says of a bitmap past what is left of the file's budget. pub(crate) const OVER_BUDGET: &str = "the bitmaps hold more pixels than one file may"; const BI_RGB: u32 = 0; -const BI_BITFIELDS: u32 = 3; -const BI_JPEG: u32 = 4; -const BI_PNG: u32 = 5; /// Pixels with straight (not premultiplied) alpha, row by row from the top. #[derive(Clone, Debug, PartialEq)] @@ -121,48 +115,6 @@ pub(crate) fn bgra32(img: &Rgba, opacity: f32) -> (Vec, Vec) { (header(img.width, img.height, 32, bits.len()), bits) } -/// How the fourth byte of 32-bit pixels reads. -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum Alpha { - /// Unused (StretchDIBits and BitBlt ignore it): every pixel opaque. - Ignore, - /// Premultiplied alpha (AlphaBlend with per-pixel alpha). - Premultiplied, -} - -/// A colour channel of `v` under `mask`, scaled to 0–255. -fn channel(v: u32, mask: u32) -> u8 { - if mask == 0 { - return 0; - } - let shift = mask.trailing_zeros(); - let max = u64::from(mask >> shift); - let x = u64::from((v & mask) >> shift); - ((x * 255 + max / 2) / max.max(1)).min(255) as u8 -} - -/// PNG `bytes` without its compressed or text ancillary chunks (`iCCP`, `zTXt`, `iTXt`, `tEXt`), -/// which the pixels don't need and which a decoder expands when it is made. Other bytes, and a PNG -/// whose chunks don't read to the end, are returned as they are. -fn png_without_metadata(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> { - const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; - if !bytes.starts_with(SIGNATURE) { - return bytes.into(); - } - let mut out = SIGNATURE.to_vec(); - let mut at = SIGNATURE.len(); - while at < bytes.len() { - let (Some(len), Some(kind)) = (bytes.get(at..at + 4), bytes.get(at + 4..at + 8)) else { return bytes.into() }; - let len = u32::from_be_bytes([len[0], len[1], len[2], len[3]]) as usize; - let Some(end) = at.checked_add(12).and_then(|n| n.checked_add(len)).filter(|end| *end <= bytes.len()) else { return bytes.into() }; - if !matches!(kind, b"iCCP" | b"zTXt" | b"iTXt" | b"tEXt") { - out.extend_from_slice(&bytes[at..end]); - } - at = end; - } - out.into() -} - /// Take a bitmap of `w` × `h` from what is `left` of the file's budget, before it is decoded. pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { let n = u64::from(w) * u64::from(h); @@ -170,215 +122,24 @@ pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { Ok(()) } -/// Read a DIB from its BITMAPINFO (`bmi`: the header and colour table) and pixel `bits`, taking its -/// pixels from what is `left` of the file's budget ([`MAX_FILE_PIXELS`]). -pub(crate) fn decode(bmi: &[u8], bits: &[u8], alpha: Alpha, left: &mut u64) -> Result { - let bad = || "a bitmap is damaged".to_string(); - let mut r = Reader::new(bmi); - let size = r.u32().ok_or_else(bad)?; - let (width, height, bpp, compression, colors, palette_entry, masks_at) = if size == 12 { - // BITMAPCOREHEADER: 16-bit sizes, RGB triples. - let w = i32::from(r.u16().ok_or_else(bad)?); - let h = i32::from(r.u16().ok_or_else(bad)?); - r.skip(2).ok_or_else(bad)?; - let bpp = r.u16().ok_or_else(bad)?; - (w, h, bpp, BI_RGB, 0, 3, 0) - } else if size >= 40 { - let w = r.i32().ok_or_else(bad)?; - let h = r.i32().ok_or_else(bad)?; - r.skip(2).ok_or_else(bad)?; - let bpp = r.u16().ok_or_else(bad)?; - let c = r.u32().ok_or_else(bad)?; - r.skip(12).ok_or_else(bad)?; - let used = r.u32().ok_or_else(bad)?; - (w, h, bpp, c, used, 4, 40) - } else { - return Err(bad()); - }; - if compression == BI_JPEG || compression == BI_PNG { - // The size comes from the image header, so an over-limit bitmap is refused before any pixels are decoded. - // A PNG's compressed ICC profile and text chunks expand when its decoder is made, and - // nothing here uses them: they are left out before the size is read and the pixels decoded. - let bits = png_without_metadata(bits); - let (w, h) = image::ImageReader::new(Cursor::new(&*bits)).with_guessed_format().map_err(|_| bad())?.into_dimensions().map_err(|_| bad())?; - if w > MAX_READ_SIDE || h > MAX_READ_SIDE || u64::from(w) * u64::from(h) > MAX_READ_PIXELS { - return Err("a bitmap is too large to read".into()); - } - charge(w, h, left)?; - let img = image::load_from_memory(&bits).map_err(|_| bad())?.to_rgba8(); - return Ok(Rgba { width: w, height: h, pixels: img.into_raw() }); - } - if compression != BI_RGB && compression != BI_BITFIELDS { - return Err("compressed (RLE) bitmaps can't be read yet".into()); - } - let top_down = height < 0; - let (w, h) = (width.unsigned_abs(), height.unsigned_abs()); - if w == 0 || h == 0 { - return Err("a bitmap is empty".into()); - } - if w > MAX_READ_SIDE || h > MAX_READ_SIDE || u64::from(w) * u64::from(h) > MAX_READ_PIXELS { - return Err("a bitmap is too large to read".into()); - } - let header_len = size as usize; - // Bit-field masks follow a plain 40-byte header, or sit inside the larger ones. - let fields = compression == BI_BITFIELDS; - let masks: Option<[u32; 4]> = fields.then(|| { - let mut m = Reader::new(bmi.get(masks_at..).unwrap_or_default()); - let rgb = [m.u32().unwrap_or(0), m.u32().unwrap_or(0), m.u32().unwrap_or(0)]; - // An alpha mask comes with BITMAPV3INFOHEADER and later. - let a = if header_len >= 56 { m.u32().unwrap_or(0) } else { 0 }; - [rgb[0], rgb[1], rgb[2], a] - }); - let palette_at = header_len + if fields && header_len == 40 { 12 } else { 0 }; - let bpp_u = u32::from(bpp); - let palette: Vec<[u8; 3]> = if bpp_u <= 8 { - let n = if colors == 0 { 1usize << bpp_u } else { (colors as usize).min(256) }; - bmi.get(palette_at..) - .unwrap_or_default() - .chunks_exact(palette_entry) - .take(n) - .map(|e| [e.get(2).copied().unwrap_or(0), e.get(1).copied().unwrap_or(0), e.first().copied().unwrap_or(0)]) - .collect() - } else { - vec![] - }; - if !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) { - return Err(format!("{bpp}-bit bitmaps can't be read")); - } - let row = stride(w, bpp_u).ok_or_else(bad)?; - if bits.len() < row.checked_mul(h as usize).ok_or_else(bad)? { - return Err(bad()); - } - charge(w, h, left)?; - let (mr, mg, mb, ma) = match (masks, bpp) { - (Some([r, g, b, a]), _) => (r, g, b, a), - (None, 16) => (0x7c00, 0x03e0, 0x001f, 0), - _ => (0x00ff_0000, 0x0000_ff00, 0x0000_00ff, 0xff00_0000), - }; - let mut pixels = Vec::with_capacity(w as usize * h as usize * 4); - for y in 0..h as usize { - let src_row = if top_down { y } else { h as usize - 1 - y }; - let line = bits.get(src_row * row..src_row * row + row).ok_or_else(bad)?; - for x in 0..w as usize { - let px: [u8; 4] = match bpp { - 1 | 4 | 8 => { - let bit = x * bpp_u as usize; - let byte = line.get(bit / 8).copied().unwrap_or(0); - let idx = (byte >> (8 - bpp_u as usize - bit % 8)) & ((1u16 << bpp_u) - 1) as u8; - let [r, g, b] = palette.get(idx as usize).copied().unwrap_or([0, 0, 0]); - [r, g, b, 255] - } - 16 => { - let v = u32::from(u16::from_le_bytes([line.get(x * 2).copied().unwrap_or(0), line.get(x * 2 + 1).copied().unwrap_or(0)])); - [channel(v, mr), channel(v, mg), channel(v, mb), 255] - } - 24 => { - let p = line.get(x * 3..x * 3 + 3).unwrap_or(&[0, 0, 0]); - [p[2], p[1], p[0], 255] - } - _ => { - let p = line.get(x * 4..x * 4 + 4).unwrap_or(&[0, 0, 0, 0]); - let v = u32::from_le_bytes([p[0], p[1], p[2], p[3]]); - let a = if alpha == Alpha::Premultiplied && ma != 0 { channel(v, ma) } else { 255 }; - let un = - |c: u8| if alpha == Alpha::Premultiplied && a < 255 { (u32::from(c) * 255 / u32::from(a.max(1))).min(255) as u8 } else { c }; - [un(channel(v, mr)), un(channel(v, mg)), un(channel(v, mb)), if alpha == Alpha::Premultiplied { a } else { 255 }] - } - }; - pixels.extend_from_slice(&px); - } - } - Ok(Rgba { width: w, height: h, pixels }) -} - -/// A packed DIB (a BITMAPINFO and its bits, one after the other, as WMF records hold them) split -/// into the two. -pub(crate) fn split(packed: &[u8]) -> Option<(&[u8], &[u8])> { - let mut r = Reader::new(packed); - let size = r.u32()? as usize; - let (bpp, compression, used, entry) = if size == 12 { - r.skip(6)?; - (r.u16()?, BI_RGB, 0, 3) - } else { - r.skip(10)?; - let bpp = r.u16()?; - let c = r.u32()?; - r.skip(12)?; - (bpp, c, r.u32()? as usize, 4) - }; - let masks = if compression == BI_BITFIELDS && size == 40 { 12 } else { 0 }; - let colors = if used > 0 { - used.min(256) - } else if bpp <= 8 { - 1usize << bpp - } else { - 0 - }; - let len = size.checked_add(masks)?.checked_add(colors * entry)?; - Some((packed.get(..len)?, packed.get(len..)?)) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn a_pngs_compressed_metadata_is_left_out_before_it_is_decoded() { - use image::ImageEncoder; - let mut png = vec![]; - let mut encoder = image::codecs::png::PngEncoder::new(&mut png); - encoder.set_icc_profile(vec![0; 1 << 20]).unwrap(); - encoder.write_image(&[10, 20, 30], 1, 1, image::ExtendedColorType::Rgb8).unwrap(); - assert!(png.windows(4).any(|w| w == b"iCCP")); - let stripped = png_without_metadata(&png); - assert!(!stripped.windows(4).any(|w| w == b"iCCP") && stripped.len() < png.len()); - let img = image::load_from_memory(&stripped).unwrap().to_rgba8(); - assert_eq!(img.get_pixel(0, 0).0, [10, 20, 30, 255]); - // Not a PNG, or a PNG cut short: unchanged. - assert_eq!(&*png_without_metadata(b"BM...."), b"BM...."); - assert_eq!(&*png_without_metadata(&png[..png.len() - 3]), &png[..png.len() - 3]); - } - fn sample() -> Rgba { // 3 × 2: red, green, blue / half-transparent black, transparent, white. let pixels = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255]; Rgba { width: 3, height: 2, pixels } } - /// A whole file's budget. - fn budget() -> u64 { - MAX_FILE_PIXELS - } - - /// A BITMAPINFOHEADER of an embedded PNG. - fn png_header() -> Vec { - let mut bmi = vec![0u8; 40]; - bmi[..4].copy_from_slice(&40u32.to_le_bytes()); - bmi[16..20].copy_from_slice(&BI_PNG.to_le_bytes()); - bmi - } - - #[test] - fn oversized_png_is_refused_from_its_header() { - // A width over the limit in the IHDR chunk: decoding the pixels would fail with a different error. - let png = crate::tests::png_claiming(MAX_READ_SIDE + 1, 1); - let err = decode(&png_header(), &png, Alpha::Ignore, &mut budget()).unwrap_err(); - assert_eq!(err, "a bitmap is too large to read"); - } - #[test] fn bitmaps_share_the_file_budget() { // #3600: each bitmap is charged before it is decoded, and one past what is left is refused. - let (bmi, bits) = rgb24(&sample(), 1.0); let mut left = 6 + 5; - assert!(decode(&bmi, &bits, Alpha::Ignore, &mut left).is_ok()); + assert!(charge(3, 2, &mut left).is_ok()); assert_eq!(left, 5); - assert_eq!(decode(&bmi, &bits, Alpha::Ignore, &mut left).unwrap_err(), OVER_BUDGET); + assert_eq!(charge(3, 2, &mut left).unwrap_err(), OVER_BUDGET); assert_eq!(left, 5, "a refused bitmap costs nothing"); - // An embedded PNG is charged from its header, so one past the budget is never decoded. - let mut left = (1 << 20) - 1; - let png = crate::tests::png_claiming(1 << 10, 1 << 10); - assert_eq!(decode(&png_header(), &png, Alpha::Ignore, &mut left).unwrap_err(), OVER_BUDGET); } #[test] @@ -402,45 +163,6 @@ mod tests { assert_eq!(&half[12..16], &[0, 0, 128, 128]); } - #[test] - fn rgb24_round_trips_over_white() { - let (bmi, bits) = rgb24(&sample(), 1.0); - assert_eq!(bmi.len(), 40); - // Rows of 9 bytes padded to 12. - assert_eq!(bits.len(), 24); - let back = decode(&bmi, &bits, Alpha::Ignore, &mut budget()).unwrap(); - assert_eq!((back.width, back.height), (3, 2)); - assert_eq!(&back.pixels[..12], &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]); - // Half-transparent black over white is mid grey; transparent is white. - assert_eq!(&back.pixels[12..16], &[127, 127, 127, 255]); - assert_eq!(&back.pixels[16..20], &[255, 255, 255, 255]); - } - - #[test] - fn bgra32_round_trips_with_alpha() { - let (bmi, bits) = bgra32(&sample(), 1.0); - let back = decode(&bmi, &bits, Alpha::Premultiplied, &mut budget()).unwrap(); - assert_eq!(&back.pixels[..12], &sample().pixels[..12]); - assert_eq!(back.pixels[15], 128); - assert_eq!(back.pixels[19], 0); - // Read as StretchDIBits does, alpha is ignored. - assert!(decode(&bmi, &bits, Alpha::Ignore, &mut budget()).unwrap().opaque()); - } - - #[test] - fn palette_bitmaps_and_damage() { - // A 1-bit 2 × 1 bitmap: black then white. - let mut bmi = header(2, 1, 1, 4); - bmi.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); - let back = decode(&bmi, &[0b0100_0000, 0, 0, 0], Alpha::Ignore, &mut budget()).unwrap(); - assert_eq!(back.pixels, vec![0, 0, 0, 255, 255, 255, 255, 255]); - // Too few bits, a silly size, an unknown depth: errors, no panic. - assert!(decode(&bmi, &[0], Alpha::Ignore, &mut budget()).is_err()); - assert!(decode(&header(1 << 20, 1 << 20, 24, 0), &[], Alpha::Ignore, &mut budget()).is_err()); - assert!(decode(&header(1, 1, 7, 0), &[0; 4], Alpha::Ignore, &mut budget()).is_err()); - assert!(decode(&[1, 2], &[], Alpha::Ignore, &mut budget()).is_err()); - } - #[test] fn large_images_are_scaled_to_fit() { let img = image::RgbaImage::new(MAX_SIDE * 2, 4); diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index da7816663e..43023e0183 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -1,16 +1,14 @@ -//! The metafile reader: the format parsers (`emf`, `wmf`, and `pict` for QuickDraw pictures) play -//! their records on a [`Player`], a small model of the device context they draw into (objects, -//! transforms, the current position, the path bracket, the clip), which turns what they draw into -//! document objects. +//! The metafile readers. EMF (with its EMF+ records) and WMF files are read by `craft_metafile`, +//! and `picture` turns what they draw into document objects. QuickDraw pictures (`pict`) play their +//! opcodes on a [`Player`], a small model of the drawing state (pen, brush, font, the clip), which +//! turns what they draw into document objects. //! //! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the //! same pen join into one path. Clips become clipping groups around what they clip. Images keep -//! their pixels (as PNG), text becomes point type in the font the file names. Records the reader -//! doesn't know are counted and skipped, with one warning. +//! their pixels (as PNG), text becomes point type in the font the file names. -mod emf; pub(crate) mod pict; -mod wmf; +mod picture; use std::sync::Arc; @@ -20,7 +18,7 @@ use vectorcraft_doc::{ Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject, }; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, Vec2, shapes}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, shapes}; use crate::dib::{self, Rgba}; use crate::{Imported, Kind}; @@ -30,24 +28,13 @@ const MAX_NODES: usize = 1 << 20; /// Objects and clips reaching further than this from the origin (points, well inside the canvas) /// are left out: they come from damaged or hostile coordinates. const LIMIT: f64 = 1e6; -/// Most nested saved device contexts. -const MAX_SAVED: usize = 1024; /// Width of a cosmetic pen (one pixel of a 96 ppi screen), points. const HAIRLINE: f64 = 0.75; -/// Curves made for arcs and ellipses are this close to the true curve (logical units are -/// transformed after, so it is relative). -const ARC_TOLERANCE: f64 = 0.01; /// Raster operations. pub(crate) const SRCCOPY: u32 = 0x00CC_0020; -const PATCOPY: u32 = 0x00F0_0021; -const WHITENESS: u32 = 0x00FF_0062; -const BLACKNESS: u32 = 0x0000_0042; const SKIPPED_ROPS: &str = "images drawn with raster operations other than a plain copy (masks, inversions) were left out"; -const HATCHES: &str = "hatched brushes are filled with their colour"; -const PATTERN_BRUSHES: &str = "pattern brushes are filled grey"; -const EXCLUDED_CLIPS: &str = "clip areas cut out of the clip (excluded rectangles, differences) are ignored"; const BAD_IMAGE: &str = "an image that couldn't be read was left out"; const TOO_MANY_PIXELS: &str = "images past the most pixels one file may hold were left out"; const FAR_AWAY: &str = "objects far outside the picture were left out"; @@ -57,6 +44,50 @@ fn sane(r: Rect) -> bool { [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) } +/// Is `path` an upright rectangle around the whole of `frame`? Writers clip to one first; it clips +/// nothing in the picture. +fn around(path: &BezPath, frame: Rect) -> bool { + if path.elements().len() > 6 { + return false; + } + let b = path.bounding_box(); + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; + let near = |p: Point, q: Point| (p - q).hypot() <= 1e-6 * (1.0 + b.width().max(b.height())); + // Each edge, the closing one included, runs along a side: from a corner to itself or to a + // neighbor (corners are in order around the box), never across it. + let along = |i: usize, j: usize| (i + 4 - j) % 4 != 2; + let mut seen = [false; 4]; + let (mut first, mut last) = (None, None); + for e in path.elements() { + let (p, start) = match e { + PathEl::MoveTo(p) => (*p, true), + PathEl::LineTo(p) => (*p, false), + PathEl::ClosePath => continue, + PathEl::QuadTo(..) | PathEl::CurveTo(..) => return false, + }; + let Some(i) = corners.iter().position(|c| near(*c, p)) else { return false }; + if start { + if let (Some(f), Some(l)) = (first, last) + && !along(f, l) + { + return false; + } + first = Some(i); + } else if last.is_some_and(|l| !along(l, i)) { + return false; + } + last = Some(i); + seen[i] = true; + } + if let (Some(f), Some(l)) = (first, last) + && !along(f, l) + { + return false; + } + let b = b.inflate(0.5, 0.5); + seen.iter().all(|s| *s) && b.contains(frame.origin()) && b.contains(Point::new(frame.x1, frame.y1)) +} + /// A pen: its style flags (`PS_*`), width in logical units, colour and dash entries. #[derive(Clone, Debug, PartialEq)] pub(crate) struct PenObj { @@ -68,7 +99,6 @@ pub(crate) struct PenObj { } impl PenObj { - pub const NULL: PenObj = PenObj { style: PS_NULL, width: 0.0, color: [0; 3], entries: vec![] }; pub fn solid(color: [u8; 3]) -> Self { Self { style: 0, width: 0.0, color, entries: vec![] } } @@ -77,28 +107,6 @@ impl PenObj { pub(crate) const PS_NULL: u32 = 5; const PS_GEOMETRIC: u32 = 0x0001_0000; -/// A brush. -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum BrushObj { - Null, - Solid([u8; 3]), - Hatched([u8; 3]), - /// A bitmap pattern (written grey). - Pattern, -} - -impl BrushObj { - /// From a LOGBRUSH's style and colour. - pub fn from_log(style: u32, color: [u8; 3]) -> Self { - match style { - 0 => Self::Solid(color), - 1 => Self::Null, - 2 => Self::Hatched(color), - _ => Self::Pattern, - } - } -} - /// A font: its height in logical units (negative: the character height, positive: the cell /// height), escapement (tenths of a degree), weight, italics and face. #[derive(Clone, Debug, Default, PartialEq)] @@ -110,35 +118,17 @@ pub(crate) struct FontObj { pub face: String, } -/// A GDI object. -#[derive(Clone, Debug)] -pub(crate) enum Obj { - Pen(PenObj), - Brush(BrushObj), - Font(FontObj), - /// Palettes, regions, colour spaces: they take a slot but don't change the drawing. - Other, -} - -/// The state records change, saved and restored as a whole. +/// The drawing state. #[derive(Clone, Debug)] struct Dc { pen: PenObj, - brush: BrushObj, + /// The brush's color. + brush: [u8; 3], font: FontObj, text_color: [u8; 3], text_align: u32, fill_rule: FillRule, miter: f64, - /// Arcs run counterclockwise (the default). - arc_ccw: bool, - /// Logical → page (EMF world transform). - world: Affine, - map_mode: u32, - win_org: Vec2, - win_ext: Vec2, - vp_org: Vec2, - vp_ext: Vec2, clips: Vec>, } @@ -146,105 +136,38 @@ impl Default for Dc { fn default() -> Self { Self { pen: PenObj::solid([0; 3]), - brush: BrushObj::Solid([255; 3]), + brush: [255; 3], font: FontObj::default(), text_color: [0; 3], text_align: 0, fill_rule: FillRule::EvenOdd, miter: 10.0, - arc_ccw: true, - world: Affine::IDENTITY, - map_mode: 1, - win_org: Vec2::ZERO, - win_ext: Vec2::new(1.0, 1.0), - vp_org: Vec2::ZERO, - vp_ext: Vec2::new(1.0, 1.0), clips: vec![], } } } -/// How an arc closes. -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum ArcKind { - Open, - Chord, - Pie, -} - pub(crate) struct Player { kind: Kind, - /// Device units (EMF) or logical units (WMF) → document points. + /// Picture units → document points. dev_to_doc: Affine, - /// Millimetres per device pixel (EMF's fixed mapping modes). - mm_per_px: f64, dc: Dc, - saved: Vec, - objects: Vec>, - /// The lowest object slot that may be free (WMF numbering). - free_hint: usize, - /// The path being recorded (document space), whether a figure is open in it and where that - /// figure started (logical). - path: Option, - figure: bool, - figure_start: Point, /// The artboard (document space): clips holding all of it clip nothing worth keeping. frame: Rect, - /// The current position (logical). - pos: Point, - /// Lines drawn one after another outside a path bracket, not drawn yet. + /// Lines drawn one after another, not drawn yet. pending: BezPath, out: Drawn, doc: Document, warnings: Vec, - skipped: usize, next_clip: u32, /// Pixels the file's bitmaps may still decode to ([`dib::MAX_FILE_PIXELS`]). pub pixels_left: u64, - /// The file holds EMF+ records (which aren't read). - pub emf_plus: bool, } fn rgb(c: [u8; 3]) -> Paint { Paint::solid(Color::rgb8(c[0], c[1], c[2])) } -/// The arc of the ellipse in `r` from the ray through `start` to the ray through `end`. -pub(crate) fn arc_path(r: Rect, start: Point, end: Point, ccw: bool, kind: ArcKind) -> BezPath { - let r = r.abs(); - let c = r.center(); - let radii = Vec2::new(r.width() / 2.0, r.height() / 2.0); - let mut bp = BezPath::new(); - if radii.x <= 0.0 || radii.y <= 0.0 { - return bp; - } - let angle = |p: Point| ((p.y - c.y) / radii.y).atan2((p.x - c.x) / radii.x); - let (a0, a1) = (angle(start), angle(end)); - let tau = std::f64::consts::TAU; - // Counterclockwise on a y-down device is towards smaller angles. - let sweep = if ccw { - let s = (a0 - a1).rem_euclid(tau); - -(if s == 0.0 { tau } else { s }) - } else { - let s = (a1 - a0).rem_euclid(tau); - if s == 0.0 { tau } else { s } - }; - let arc = kurbo::Arc { center: c, radii, start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; - let tol = ARC_TOLERANCE * radii.x.max(radii.y).max(1e-9); - if kind == ArcKind::Pie { - bp.move_to(c); - let first = c + Vec2::new(radii.x * a0.cos(), radii.y * a0.sin()); - bp.line_to(first); - arc.append_iter(tol).for_each(|el| bp.push(el)); - } else { - kurbo::Shape::path_elements(&arc, tol).for_each(|el| bp.push(el)); - } - if kind != ArcKind::Open { - bp.close_path(); - } - bp -} - /// A rectangle with elliptical corners `rx` × `ry`. pub(crate) fn round_rect(r: Rect, rx: f64, ry: f64) -> BezPath { let r = r.abs(); @@ -261,7 +184,7 @@ pub(crate) fn round_rect(r: Rect, rx: f64, ry: f64) -> BezPath { } impl Player { - fn new(kind: Kind, dev_to_doc: Affine, mm_per_px: f64, artboard: Rect) -> Self { + fn new(kind: Kind, dev_to_doc: Affine, artboard: Rect) -> Self { let side = |v: f64| if v.is_finite() { v.clamp(1.0, LIMIT) } else { 1.0 }; let (w, h) = (side(artboard.width()), side(artboard.height())); let mut doc = Document::new(w, h); @@ -269,24 +192,14 @@ impl Player { Self { kind, dev_to_doc, - mm_per_px, dc: Dc::default(), - saved: vec![], - objects: vec![], - free_hint: 0, - path: None, - figure: false, - figure_start: Point::ZERO, frame: artboard, - pos: Point::ZERO, pending: BezPath::new(), out: vec![], doc, warnings: vec![], - skipped: 0, next_clip: 0, pixels_left: dib::MAX_FILE_PIXELS, - emf_plus: false, } } @@ -296,188 +209,19 @@ impl Player { } } - /// A record of a kind the reader doesn't know. - pub fn skip(&mut self) { - self.skipped += 1; - } - // ---------- mapping ---------- - /// Logical → document. + /// Picture units → document. fn xf(&self) -> Affine { - if self.kind == Kind::Wmf { - return self.dev_to_doc; - } - let d = &self.dc; - let page_to_dev = match d.map_mode { - // Fixed modes: millimetres a unit, y up. - 2..=6 => { - let mm = [0.1, 0.01, 0.254, 0.0254, 25.4 / 1440.0][(d.map_mode - 2) as usize]; - let s = mm / self.mm_per_px.max(1e-12); - Affine::translate(d.vp_org) * Affine::scale_non_uniform(s, -s) * Affine::translate(-d.win_org) - } - 7 | 8 => { - let ratio = |v: f64, w: f64| if w.abs() > 1e-12 && v.is_finite() { v / w } else { 1.0 }; - let (mut sx, mut sy) = (ratio(d.vp_ext.x, d.win_ext.x), ratio(d.vp_ext.y, d.win_ext.y)); - if d.map_mode == 7 { - // Isotropic: the same scale both ways, the smaller one. - let s = sx.abs().min(sy.abs()); - (sx, sy) = (s.copysign(sx), s.copysign(sy)); - } - Affine::translate(d.vp_org) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-d.win_org) - } - _ => Affine::translate(d.vp_org - d.win_org), - }; - self.dev_to_doc * page_to_dev * d.world + self.dev_to_doc } - /// How much logical lengths grow in the document. + /// How much picture lengths grow in the document. fn scale(&self) -> f64 { let s = self.xf().determinant().abs().sqrt(); if s.is_finite() { s } else { 0.0 } } - pub fn set_map_mode(&mut self, m: u32) { - self.dc.map_mode = m; - } - pub fn set_window_org(&mut self, x: f64, y: f64) { - self.dc.win_org = Vec2::new(x, y); - } - pub fn set_window_ext(&mut self, x: f64, y: f64) { - self.dc.win_ext = Vec2::new(x, y); - } - pub fn set_viewport_org(&mut self, x: f64, y: f64) { - self.dc.vp_org = Vec2::new(x, y); - } - pub fn set_viewport_ext(&mut self, x: f64, y: f64) { - self.dc.vp_ext = Vec2::new(x, y); - } - pub fn scale_ext(&mut self, window: bool, x: (f64, f64), y: (f64, f64)) { - let e = if window { &mut self.dc.win_ext } else { &mut self.dc.vp_ext }; - if x.1 != 0.0 && y.1 != 0.0 { - *e = Vec2::new(e.x * x.0 / x.1, e.y * y.0 / y.1); - } - } - /// `SetWorldTransform` / `ModifyWorldTransform` (`mode`: 1 identity, 2 left, 3 right, 4 set). - pub fn world(&mut self, x: Affine, mode: u32) { - if !x.as_coeffs().iter().all(|v| v.is_finite()) { - return; - } - self.dc.world = match mode { - 1 => Affine::IDENTITY, - 2 => self.dc.world * x, - 3 => x * self.dc.world, - _ => x, - }; - } - - // ---------- state ---------- - - pub fn set_fill_mode(&mut self, alternate: bool) { - self.dc.fill_rule = if alternate { FillRule::EvenOdd } else { FillRule::NonZero }; - } - pub fn set_miter(&mut self, m: f64) { - if m.is_finite() && m >= 1.0 { - self.dc.miter = m; - } - } - pub fn set_arc_ccw(&mut self, ccw: bool) { - self.dc.arc_ccw = ccw; - } - pub fn set_text_color(&mut self, c: [u8; 3]) { - self.dc.text_color = c; - } - pub fn set_text_align(&mut self, a: u32) { - self.dc.text_align = a; - } - - pub fn save(&mut self) { - if self.saved.len() < MAX_SAVED { - self.saved.push(self.dc.clone()); - } - } - - /// `RestoreDC(n)`: a negative `n` counts back from the latest save, a positive one is a save's - /// number. - pub fn restore(&mut self, n: i32) { - let len = self.saved.len(); - let keep = if n < 0 { len.checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1).filter(|k| *k < len) }; - if let Some(k) = keep { - self.saved.truncate(k + 1); - if let Some(dc) = self.saved.pop() { - self.dc = dc; - } - } - } - - // ---------- objects ---------- - - /// Put `obj` in slot `index` (EMF numbers its objects). - pub fn create_at(&mut self, index: u32, obj: Obj) { - let i = index as usize; - if i == 0 || i > u16::MAX as usize { - return; - } - if self.objects.len() <= i { - self.objects.resize(i + 1, None); - } - if let Some(s) = self.objects.get_mut(i) { - *s = Some(obj); - } - } - - /// Put `obj` in the lowest free slot (WMF). - pub fn create(&mut self, obj: Obj) { - let start = self.free_hint.min(self.objects.len()); - let i = self.objects.iter().skip(start).position(Option::is_none).map_or(self.objects.len(), |p| p + start); - if i > u16::MAX as usize { - // The table is full past the hint: the next creation looks from the end, not again - // through every slot. - self.free_hint = i; - return; - } - if i == self.objects.len() { - self.objects.push(None); - } - if let Some(s) = self.objects.get_mut(i) { - *s = Some(obj); - } - self.free_hint = i + 1; - } - - pub fn delete(&mut self, index: u32) { - if let Some(s) = self.objects.get_mut(index as usize) { - *s = None; - self.free_hint = self.free_hint.min(index as usize); - } - } - - /// Select object `index`, or a stock object (EMF: the high bit set). - pub fn select(&mut self, index: u32) { - if self.kind == Kind::Emf && index & 0x8000_0000 != 0 { - match index & 0x7fff_ffff { - 0 | 18 => self.dc.brush = BrushObj::Solid([255; 3]), - 1 => self.dc.brush = BrushObj::Solid([0xc0; 3]), - 2 => self.dc.brush = BrushObj::Solid([0x80; 3]), - 3 => self.dc.brush = BrushObj::Solid([0x40; 3]), - 4 => self.dc.brush = BrushObj::Solid([0; 3]), - 5 => self.dc.brush = BrushObj::Null, - 6 => self.dc.pen = PenObj::solid([255; 3]), - 7 | 19 => self.dc.pen = PenObj::solid([0; 3]), - 8 => self.dc.pen = PenObj::NULL, - 10..=17 => self.dc.font = FontObj::default(), - _ => {} - } - return; - } - match self.objects.get(index as usize).cloned().flatten() { - Some(Obj::Pen(p)) => self.dc.pen = p, - Some(Obj::Brush(b)) => self.dc.brush = b, - Some(Obj::Font(f)) => self.dc.font = f, - _ => {} - } - } - // ---------- output ---------- fn push(&mut self, mut node: Node) { @@ -491,19 +235,8 @@ impl Player { self.out.push((self.dc.clips.clone(), node)); } - fn fill_paint(&mut self) -> Option { - match self.dc.brush.clone() { - BrushObj::Null => None, - BrushObj::Solid(c) => Some(rgb(c)), - BrushObj::Hatched(c) => { - self.warn(HATCHES); - Some(rgb(c)) - } - BrushObj::Pattern => { - self.warn(PATTERN_BRUSHES); - Some(rgb([0x80; 3])) - } - } + fn fill_paint(&self) -> Paint { + rgb(self.dc.brush) } fn stroke_layer(&self) -> Option { @@ -549,8 +282,8 @@ impl Player { return; } let mut items = vec![]; - if fill && let Some(p) = self.fill_paint() { - items.push(AppearanceItem::Fill(FillLayer::new(p))); + if fill { + items.push(AppearanceItem::Fill(FillLayer::new(self.fill_paint()))); } if stroke && let Some(st) = self.stroke_layer() { items.push(AppearanceItem::Stroke(st)); @@ -573,124 +306,7 @@ impl Player { } } - /// A figure in logical units: added to the path being recorded, else drawn (filled when it is - /// an area). - pub fn figure(&mut self, mut bp: BezPath, area: bool) { - bp.apply_affine(self.xf()); - match &mut self.path { - Some(path) => { - path.extend(bp); - self.figure = false; - } - None => self.draw(bp, area, true), - } - } - - /// Polygon / Polyline (and Bézier ones): `pts` in logical units. - pub fn poly(&mut self, pts: &[Point], closed: bool, bezier: bool) { - let Some((first, rest)) = pts.split_first() else { return }; - let mut bp = BezPath::new(); - bp.move_to(*first); - if bezier { - for c in rest.as_chunks::<3>().0 { - bp.curve_to(c[0], c[1], c[2]); - } - } else { - rest.iter().for_each(|p| bp.line_to(*p)); - } - if closed { - bp.close_path(); - } - self.figure(bp, closed); - } - - /// Several polygons (one area) or polylines. - pub fn poly_poly(&mut self, polys: &[Vec], closed: bool) { - let mut bp = BezPath::new(); - for p in polys { - let Some((first, rest)) = p.split_first() else { continue }; - bp.move_to(*first); - rest.iter().for_each(|q| bp.line_to(*q)); - if closed { - bp.close_path(); - } - } - self.figure(bp, closed); - } - - pub fn move_to(&mut self, p: Point) { - self.pos = p; - self.figure = false; - } - - /// LineTo / PolylineTo / PolyBezierTo: from the current position through `pts` (logical). - pub fn poly_to(&mut self, pts: &[Point], bezier: bool) { - let Some(last) = pts.last().copied() else { return }; - let xf = self.xf(); - let start = xf * self.pos; - let target = match &mut self.path { - Some(path) => { - if !self.figure { - path.move_to(start); - self.figure = true; - self.figure_start = self.pos; - } - path - } - None => { - if self.pending.elements().last().and_then(|e| e.end_point()) != Some(start) { - self.pending.move_to(start); - } - &mut self.pending - } - }; - if bezier { - for c in pts.as_chunks::<3>().0 { - target.curve_to(xf * c[0], xf * c[1], xf * c[2]); - } - } else { - pts.iter().for_each(|p| target.line_to(xf * *p)); - } - self.pos = last; - } - - pub fn arc(&mut self, r: Rect, start: Point, end: Point, kind: ArcKind) { - let bp = arc_path(r, start, end, self.dc.arc_ccw, kind); - self.figure(bp, kind != ArcKind::Open); - } - - // ---------- paths and clips ---------- - - pub fn begin_path(&mut self) { - self.path = Some(BezPath::new()); - self.figure = false; - } - pub fn end_path(&mut self) { - // The path stays until it is filled, stroked or made the clip. - } - pub fn close_figure(&mut self) { - if let Some(p) = &mut self.path - && self.figure - { - p.close_path(); - self.figure = false; - self.pos = self.figure_start; - } - } - pub fn abort_path(&mut self) { - self.path = None; - } - - /// FillPath / StrokePath / StrokeAndFillPath. - pub fn paint_path(&mut self, fill: bool, stroke: bool) { - if let Some(mut bp) = self.path.take() { - // Filling closes the open figure. - if fill && self.figure { - bp.close_path(); - } - self.draw(bp, fill, stroke); - } - } + // ---------- clips ---------- fn push_clip(&mut self, path: BezPath, rule: FillRule, replace: bool) { if replace { @@ -699,10 +315,7 @@ impl Player { if !sane(path.bounding_box()) { return self.warn(FAR_AWAY); } - // A rectangle around the whole picture (what writers clip to first) clips nothing in it. - let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..))); - let around = path.bounding_box().inflate(0.5, 0.5); - if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) { + if around(&path, self.frame) { return; } if self.dc.clips.len() >= MAX_NEST { @@ -712,69 +325,10 @@ impl Player { self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })); } - /// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read). - pub fn clip_path(&mut self, mode: u32) { - let Some(bp) = self.path.take() else { return }; - match mode { - 1 => self.push_clip(bp, self.dc.fill_rule, false), - 5 => self.push_clip(bp, self.dc.fill_rule, true), - _ => self.warn(EXCLUDED_CLIPS), - } - } - - /// IntersectClipRect (logical units). - pub fn clip_rect(&mut self, r: Rect) { - let mut bp = shapes::rectangle(r.abs()).to_bezpath(); - bp.apply_affine(self.xf()); - self.push_clip(bp, FillRule::NonZero, false); - } - - /// A clip region of `rects` in device units (`mode` as [`Self::clip_path`]); none with mode - /// copy removes the clip. - pub fn clip_region(&mut self, rects: &[Rect], mode: u32) { - if rects.is_empty() { - if mode == 5 { - self.dc.clips.clear(); - } - return; - } - let mut bp = BezPath::new(); - for r in rects { - bp.extend(shapes::rectangle(r.abs()).to_bezpath()); - } - bp.apply_affine(self.dev_to_doc); - match mode { - 1 => self.push_clip(bp, FillRule::NonZero, false), - 5 => self.push_clip(bp, FillRule::NonZero, true), - _ => self.warn(EXCLUDED_CLIPS), - } - } - - pub fn exclude_clip(&mut self) { - self.warn(EXCLUDED_CLIPS); - } - - // ---------- blits, images and text ---------- - - /// A raster operation without a source over `dest` (logical): the brush, white or black. - pub fn pattern_blit(&mut self, dest: Rect, rop: u32) { - let brush = match rop { - PATCOPY => self.dc.brush.clone(), - WHITENESS => BrushObj::Solid([255; 3]), - BLACKNESS => BrushObj::Solid([0; 3]), - _ => return self.skip(), - }; - let pen = std::mem::replace(&mut self.dc.pen, PenObj::NULL); - let outer = std::mem::replace(&mut self.dc.brush, brush); - let mut bp = shapes::rectangle(dest.abs()).to_bezpath(); - bp.apply_affine(self.xf()); - self.draw(bp, true, false); - self.dc.pen = pen; - self.dc.brush = outer; - } + // ---------- images and text ---------- /// An image whose source rectangle `src` (pixels, from the top-left) is drawn into `dest` - /// (logical: origin and signed size) with raster operation `rop`, at `opacity`. + /// (picture units: origin and signed size) with raster operation `rop`, at `opacity`. pub fn image(&mut self, img: Result, src: Rect, dest: Rect, rop: u32, opacity: f64) { if rop != SRCCOPY { self.warn(SKIPPED_ROPS); @@ -793,8 +347,8 @@ impl Player { self.image_xf(img, Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h), opacity); } - /// An image placed by `xf` (its pixels, from the top-left, → logical), at `opacity`. An image - /// that `xf` flattens or sends past finite numbers is left out, and adds nothing to the + /// An image placed by `xf` (its pixels, from the top-left, → picture units), at `opacity`. An + /// image that `xf` flattens or sends past finite numbers is left out, and adds nothing to the /// document's images. pub(crate) fn image_xf(&mut self, img: Rgba, xf: Affine, opacity: f64) { if img.width == 0 || img.height == 0 { @@ -814,14 +368,13 @@ impl Player { self.push(n); } - /// Text at `at` (logical; the current position with TA_UPDATECP) in the current font. + /// Text at `at` (picture units) in the current font. pub fn text(&mut self, at: Point, s: &str) { let s: String = s.chars().filter(|c| *c != '\0').collect(); if s.trim().is_empty() { return; } let align = self.dc.text_align; - let at = if align & 1 != 0 { self.pos } else { at }; let f = self.dc.font.clone(); let k = self.scale(); // A negative height is the em; a positive one the cell (the em and the internal leading). @@ -863,18 +416,7 @@ impl Player { /// The document: what was drawn on one layer, clipped art in clipping groups. fn finish(mut self) -> Imported { self.flush(); - if self.skipped > 0 { - let n = self.skipped; - self.warnings.push(format!( - "{n} record{} of kinds VectorCraft doesn't read {} skipped", - if n == 1 { "" } else { "s" }, - if n == 1 { "was" } else { "were" } - )); - } let out = std::mem::take(&mut self.out); - if out.is_empty() && self.emf_plus { - self.warnings.push("the picture is drawn with EMF+ records, which aren't read yet: nothing was imported".into()); - } let children = nest(&mut self.doc, out); let mut layer = Node::layer(self.doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); if let Some(c) = layer.children_mut() { @@ -904,33 +446,7 @@ fn crop(img: Rgba, src: Rect) -> Rgba { Rgba { width: x1 - x0, height: y1 - y0, pixels } } -/// Read a metafile. +/// Read an EMF or WMF file. pub(crate) fn import(bytes: &[u8]) -> Result { - match crate::sniff(bytes) { - Some(Kind::Emf) => emf::play(bytes), - Some(Kind::Wmf) => wmf::play(bytes), - None => Err("not an EMF or WMF file".into()), - } -} - -#[cfg(test)] -mod table_tests { - use super::*; - - /// Once the WMF table is full, a rejected creation leaves the hint at its end, so the next - /// ones don't scan every slot again; a freed slot is still found. - #[test] - fn a_full_object_table_is_not_rescanned() { - let mut p = Player::new(Kind::Wmf, Affine::IDENTITY, 1.0, Rect::new(0.0, 0.0, 10.0, 10.0)); - for _ in 0..=u16::MAX as usize + 1 { - p.create(Obj::Other); - } - p.delete(5); - p.create(Obj::Other); - p.create(Obj::Other); - assert!(p.free_hint > u16::MAX as usize); - p.delete(7); - p.create(Obj::Other); - assert!(p.objects[7].is_some()); - } + picture::import(bytes) } diff --git a/crates/metafile/src/import/emf.rs b/crates/metafile/src/import/emf.rs deleted file mode 100644 index ece16c7864..0000000000 --- a/crates/metafile/src/import/emf.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! EMF records (MS-EMF) played on a [`Player`]: the header gives the frame (the artboard) and the -//! reference device (device pixels → millimetres), then every record in turn until EMR_EOF. - -use vectorcraft_geom::{Affine, Point, Rect}; - -use super::{ArcKind, BrushObj, FontObj, Obj, PenObj, Player}; -use crate::bytes::{Reader, slice}; -use crate::dib::{self, Alpha}; -use crate::{Imported, Kind}; - -/// Points per millimetre. -const PT_PER_MM: f64 = 72.0 / 25.4; -/// Most points one record may give (a hostile count can't make a huge allocation). -const MAX_POINTS: usize = 1 << 22; -/// Records that change nothing VectorCraft keeps (background mode and colour, raster modes, -/// palettes, colour management, comments…). -const IGNORED: &[u32] = - &[1, 13, 15, 16, 18, 20, 21, 23, 25, 28, 48, 50, 51, 52, 65, 66, 70, 98, 100, 101, 104, 110, 111, 112, 113, 115, 119, 120, 121]; - -fn rect(r: &mut Reader) -> Option { - Some(Rect::new(f64::from(r.i32()?), f64::from(r.i32()?), f64::from(r.i32()?), f64::from(r.i32()?))) -} - -fn point32(r: &mut Reader) -> Option { - Some(Point::new(f64::from(r.i32()?), f64::from(r.i32()?))) -} - -fn point16(r: &mut Reader) -> Option { - Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?))) -} - -fn color(r: &mut Reader) -> Option<[u8; 3]> { - let [red, g, b, _] = r.u32()?.to_le_bytes(); - Some([red, g, b]) -} - -fn xform(r: &mut Reader) -> Option { - let mut v = [0.0; 6]; - for x in &mut v { - *x = f64::from(r.f32()?); - } - Some(Affine::new(v)) -} - -/// `n` points (32-bit or 16-bit), `None` when the record is shorter. -fn points(r: &mut Reader, n: u32, wide: bool) -> Option> { - let n = n as usize; - if n > MAX_POINTS || r.rest().len() < n * if wide { 8 } else { 4 } { - return None; - } - (0..n).map(|_| if wide { point32(r) } else { point16(r) }).collect() -} - -/// The polygons of a PolyPolygon / PolyPolyline record. -fn polys(r: &mut Reader, wide: bool) -> Option>> { - r.skip(16)?; - let n = r.u32()? as usize; - let total = r.u32()?; - if n > MAX_POINTS || r.rest().len() < n * 4 { - return None; - } - let counts: Vec = (0..n).map(|_| r.u32()).collect::>()?; - let mut all = points(r, total, wide)?.into_iter(); - Some(counts.iter().map(|c| all.by_ref().take(*c as usize).collect()).collect()) -} - -/// A LOGFONTW → the font. -fn font(r: &mut Reader) -> Option { - let height = f64::from(r.i32()?); - r.skip(4)?; - let escapement = f64::from(r.i32()?); - r.skip(4)?; - let weight = r.i32()?; - let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0); - r.skip(7)?; - let units: Vec = (0..32).map_while(|_| r.u16()).take_while(|c| *c != 0).collect(); - Some(FontObj { height, escapement, weight, italic, face: String::from_utf16_lossy(&units) }) -} - -/// The bitmap of an image record: its BITMAPINFO and bits at the offsets the record gives. -fn bitmap(rec: &[u8], off_bmi: u32, cb_bmi: u32, off_bits: u32, cb_bits: u32, alpha: Alpha, left: &mut u64) -> Result { - let bmi = slice(rec, off_bmi, cb_bmi).ok_or("damaged")?; - let bits = slice(rec, off_bits, cb_bits).ok_or("damaged")?; - dib::decode(bmi, bits, alpha, left) -} - -/// The rows a DIB's source rectangle counts from the bottom in (StretchDIBits on a bottom-up DIB). -fn bottom_up(rec: &[u8], off_bmi: u32) -> bool { - let at = off_bmi as usize; - let size = rec.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); - let height = rec.get(at + 8..at + 12).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes); - size.is_some_and(|s| s >= 40) && height.is_some_and(|h| h > 0) -} - -/// The source rectangle `(x, y, w, h)` of an image of `img`'s height, counted from the top. -fn source(x: i32, y: i32, w: i32, h: i32, img: &Result, from_bottom: bool) -> Rect { - let ih = img.as_ref().map_or(0.0, |i| f64::from(i.height)); - let (x, y, w, h) = (f64::from(x), f64::from(y), f64::from(w), f64::from(h)); - let top = if from_bottom { ih - y - h } else { y }; - Rect::new(x, top, x + w, top + h) -} - -/// The size and placement of the picture: (device → document, millimetres a pixel, the artboard). -fn frame(bytes: &[u8]) -> Option<(Affine, f64, Rect)> { - let mut r = Reader::new(bytes); - r.skip(4)?; - let header_size = r.u32()?; - let bounds = rect(&mut r)?; - let frame = rect(&mut r)?; - // Signature, version, bytes, records, handles, description and palette size. - r.skip(32)?; - let device = (f64::from(r.i32()?), f64::from(r.i32()?)); - let mm = (f64::from(r.i32()?), f64::from(r.i32()?)); - // The device in micrometres (more precise), when the header has it. - let micro = if header_size >= 108 { - r.skip(12)?; - Some((f64::from(r.u32()?), f64::from(r.u32()?))) - } else { - None - }; - let ok = |v: f64| v.is_finite() && v > 0.0; - let mm_per_px = match micro { - Some((mx, _)) if ok(mx) && ok(device.0) => mx / 1000.0 / device.0, - _ if ok(mm.0) && ok(device.0) => mm.0 / device.0, - _ => 25.4 / 96.0, - }; - let mm_per_px = if mm_per_px.is_finite() && mm_per_px > 1e-9 { mm_per_px } else { 25.4 / 96.0 }; - let k = mm_per_px * PT_PER_MM; - // The frame (0.01 mm) in device pixels; an empty frame falls back to the bounds. - let f = frame.abs(); - let (origin, size) = if f.width() > 0.0 && f.height() > 0.0 { - (Point::new(f.x0 / 100.0 / mm_per_px, f.y0 / 100.0 / mm_per_px), (f.width() / 100.0 * PT_PER_MM, f.height() / 100.0 * PT_PER_MM)) - } else { - let b = bounds.abs(); - (b.origin(), ((b.width() + 1.0) * k, (b.height() + 1.0) * k)) - }; - let dev_to_doc = Affine::scale(k) * Affine::translate(-origin.to_vec2()); - let side = |v: f64| if v.is_finite() { v.clamp(1.0, 1e6) } else { 1.0 }; - Some((dev_to_doc, mm_per_px, Rect::new(0.0, 0.0, side(size.0), side(size.1)))) -} - -pub(super) fn play(bytes: &[u8]) -> Result { - let (dev_to_doc, mm_per_px, artboard) = frame(bytes).ok_or("the EMF header is damaged")?; - let mut p = Player::new(Kind::Emf, dev_to_doc, mm_per_px, artboard); - let mut at = 0usize; - let mut ended = false; - while let Some(head) = bytes.get(at..at + 8) { - let kind = u32::from_le_bytes([head[0], head[1], head[2], head[3]]); - let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; - let Some(rec) = bytes.get(at..at.saturating_add(size)).filter(|_| size >= 8) else { break }; - if kind == 14 { - ended = true; - break; - } - if !matches!(kind, 27 | 54 | 5 | 6 | 88 | 89) { - p.flush(); - } - if record(&mut p, kind, rec).is_none() { - p.skip(); - } - at += size; - } - if !ended { - p.warn("the file ends early: what it held was imported"); - } - Ok(p.finish()) -} - -/// PolyBezier, Polygon, Polyline, PolyBezierTo, PolylineTo and their 16-bit forms. -fn rec_poly(p: &mut Player, kind: u32, r: &mut Reader) -> Option<()> { - let wide = kind < 85; - r.skip(16)?; - let n = r.u32()?; - let pts = points(r, n, wide)?; - match if wide { kind } else { kind - 83 } { - 2 => p.poly(&pts, false, true), - 3 => p.poly(&pts, true, false), - 4 => p.poly(&pts, false, false), - 5 => p.poly_to(&pts, true), - _ => p.poly_to(&pts, false), - } - Some(()) -} - -/// CreatePen (38). -fn rec_create_pen(p: &mut Player, r: &mut Reader) -> Option<()> { - let ih = r.u32()?; - let style = r.u32()?; - let width = f64::from(r.i32()?); - r.skip(4)?; - p.create_at(ih, Obj::Pen(PenObj { style, width, color: color(r)?, entries: vec![] })); - Some(()) -} - -/// ExtCreatePen (95). -fn rec_ext_create_pen(p: &mut Player, r: &mut Reader) -> Option<()> { - let ih = r.u32()?; - r.skip(16)?; - let style = r.u32()?; - let width = f64::from(r.u32()?); - let brush = r.u32()?; - let c = color(r)?; - r.skip(4)?; - let n = (r.u32()? as usize).min(64); - let entries: Vec = (0..n).map_while(|_| r.u32()).map(f64::from).collect(); - // A pen painted with the null brush draws nothing. - let style = if brush == 1 { super::PS_NULL } else { style }; - p.create_at(ih, Obj::Pen(PenObj { style, width, color: c, entries })); - Some(()) -} - -/// GdiComment (70): EMF+ records ride in comments starting "EMF+". -fn rec_comment(p: &mut Player, r: &mut Reader) -> Option<()> { - r.skip(4)?; - p.emf_plus |= r.u32()? == 0x2B46_4D45; - Some(()) -} - -/// ExtSelectClipRgn (75). -fn rec_clip_region(p: &mut Player, r: &mut Reader) -> Option<()> { - let cb = r.u32()?; - let mode = r.u32()?; - let data = r.bytes(cb as usize)?; - let mut d = Reader::new(data); - let rects = if cb >= 32 { - d.skip(8)?; - let n = d.u32()? as usize; - d.skip(20)?; - if n > MAX_POINTS || d.rest().len() < n * 16 { - return None; - } - (0..n).map(|_| rect(&mut d)).collect::>>()? - } else { - vec![] - }; - p.clip_region(&rects, mode); - Some(()) -} - -/// The rectangle at `(x, y)` of size `(w, h)`. -fn at_size(x: i32, y: i32, w: i32, h: i32) -> Rect { - Rect::new(f64::from(x), f64::from(y), f64::from(x) + f64::from(w), f64::from(y) + f64::from(h)) -} - -/// BitBlt (76) and StretchBlt (77). -fn rec_blt(p: &mut Player, kind: u32, rec: &[u8], r: &mut Reader) -> Option<()> { - r.skip(16)?; - let dest = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let rop = r.u32()?; - let (xs, ys) = (r.i32()?, r.i32()?); - r.skip(24 + 4 + 4)?; - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - let (ws, hs) = if kind == 77 { (r.i32()?, r.i32()?) } else { (dest.2, dest.3) }; - let d = at_size(dest.0, dest.1, dest.2, dest.3); - if cb_bmi == 0 { - p.pattern_blit(d, rop); - } else { - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, false); - p.image(img, src, d, rop, 1.0); - } - Some(()) -} - -/// StretchDIBits (81). -fn rec_stretch_dibits(p: &mut Player, rec: &[u8], r: &mut Reader) -> Option<()> { - r.skip(16)?; - let (xd, yd, xs, ys, ws, hs) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - r.skip(4)?; - let rop = r.u32()?; - let (wd, hd) = (r.i32()?, r.i32()?); - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, bottom_up(rec, off_bmi)); - p.image(img, src, at_size(xd, yd, wd, hd), rop, 1.0); - Some(()) -} - -/// AlphaBlend (114). -fn rec_alpha_blend(p: &mut Player, rec: &[u8], r: &mut Reader) -> Option<()> { - r.skip(16)?; - let (xd, yd, wd, hd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let blend = r.bytes(4)?; - let (constant, per_pixel) = (blend.get(2).copied().unwrap_or(255), blend.get(3).is_some_and(|f| *f & 1 != 0)); - let (xs, ys) = (r.i32()?, r.i32()?); - r.skip(24 + 4 + 4)?; - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - let (ws, hs) = (r.i32()?, r.i32()?); - let alpha = if per_pixel { Alpha::Premultiplied } else { Alpha::Ignore }; - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, alpha, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, false); - p.image(img, src, at_size(xd, yd, wd, hd), crate::import::SRCCOPY, f64::from(constant) / 255.0); - Some(()) -} - -/// ExtTextOutA (83) and ExtTextOutW (84). -fn rec_text(p: &mut Player, kind: u32, rec: &[u8], r: &mut Reader) -> Option<()> { - r.skip(16 + 4 + 8)?; - let at = point32(r)?; - let n = r.u32()? as usize; - let off = r.u32()?; - let s = if kind == 84 { - let raw = slice(rec, off, u32::try_from(n.checked_mul(2)?).ok()?)?; - String::from_utf16_lossy(&raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect::>()) - } else { - slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect() - }; - p.text(at, &s); - Some(()) -} - -/// Play one record (`rec`: all of it, with its type and size). `None`: unknown or damaged. -fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> { - let mut r = Reader::new(rec.get(8..)?); - match kind { - 2..=6 | 85..=89 => rec_poly(p, kind, &mut r)?, - 7 | 90 => p.poly_poly(&polys(&mut r, kind == 7)?, false), - 8 | 91 => p.poly_poly(&polys(&mut r, kind == 8)?, true), - 9 => p.set_window_ext(f64::from(r.i32()?), f64::from(r.i32()?)), - 10 => p.set_window_org(f64::from(r.i32()?), f64::from(r.i32()?)), - 11 => p.set_viewport_ext(f64::from(r.i32()?), f64::from(r.i32()?)), - 12 => p.set_viewport_org(f64::from(r.i32()?), f64::from(r.i32()?)), - 17 => p.set_map_mode(r.u32()?), - 19 => p.set_fill_mode(r.u32()? == 1), - 22 => p.set_text_align(r.u32()?), - 24 => p.set_text_color(color(&mut r)?), - 27 => p.move_to(point32(&mut r)?), - 29 => p.exclude_clip(), - 30 => p.clip_rect(rect(&mut r)?), - 31 | 32 => { - let (xn, xd, yn, yd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - p.scale_ext(kind == 32, (f64::from(xn), f64::from(xd)), (f64::from(yn), f64::from(yd))); - } - 33 => p.save(), - 34 => p.restore(r.i32()?), - 35 => p.world(xform(&mut r)?, 4), - 36 => { - let x = xform(&mut r)?; - p.world(x, r.u32()?); - } - 37 => p.select(r.u32()?), - 38 => rec_create_pen(p, &mut r)?, - 39 => { - let ih = r.u32()?; - let style = r.u32()?; - p.create_at(ih, Obj::Brush(BrushObj::from_log(style, color(&mut r)?))); - } - 40 => p.delete(r.u32()?), - 42 => p.figure(vectorcraft_geom::shapes::ellipse(rect(&mut r)?.abs()).to_bezpath(), true), - 43 => p.figure(vectorcraft_geom::shapes::rectangle(rect(&mut r)?.abs()).to_bezpath(), true), - 44 => { - let b = rect(&mut r)?; - let (cx, cy) = (f64::from(r.i32()?), f64::from(r.i32()?)); - p.figure(super::round_rect(b, cx, cy), true); - } - 45..=47 => { - let b = rect(&mut r)?; - let (s, e) = (point32(&mut r)?, point32(&mut r)?); - p.arc(b, s, e, [ArcKind::Open, ArcKind::Chord, ArcKind::Pie][(kind - 45) as usize]); - } - 49 | 99 | 122 => p.create_at(r.u32()?, Obj::Other), - 54 => { - let pt = point32(&mut r)?; - p.poly_to(&[pt], false); - } - 57 => p.set_arc_ccw(r.u32()? != 2), - 58 => p.set_miter(f64::from(r.u32()?)), - 59 => p.begin_path(), - 60 => p.end_path(), - 61 => p.close_figure(), - 62 => p.paint_path(true, false), - 63 => p.paint_path(true, true), - 64 => p.paint_path(false, true), - 67 => p.clip_path(r.u32()?), - 68 => p.abort_path(), - 70 => rec_comment(p, &mut r)?, - 75 => rec_clip_region(p, &mut r)?, - 76 | 77 => rec_blt(p, kind, rec, &mut r)?, - 81 => rec_stretch_dibits(p, rec, &mut r)?, - 82 => { - let ih = r.u32()?; - p.create_at(ih, Obj::Font(font(&mut r)?)); - } - 83 | 84 => rec_text(p, kind, rec, &mut r)?, - 93 | 94 => p.create_at(r.u32()?, Obj::Brush(BrushObj::Pattern)), - 95 => rec_ext_create_pen(p, &mut r)?, - 114 => rec_alpha_blend(p, rec, &mut r)?, - k if IGNORED.contains(&k) => {} - _ => return None, - } - Some(()) -} diff --git a/crates/metafile/src/import/pict.rs b/crates/metafile/src/import/pict.rs index a9b4cae9da..f6abbc6b83 100644 --- a/crates/metafile/src/import/pict.rs +++ b/crates/metafile/src/import/pict.rs @@ -24,7 +24,7 @@ use std::io::Cursor; use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect, Shape, Vec2, shapes}; -use super::{BAD_IMAGE, BrushObj, FontObj, PS_GEOMETRIC, PenObj, Player, SRCCOPY, TOO_MANY_PIXELS, crop, round_rect}; +use super::{BAD_IMAGE, FontObj, PS_GEOMETRIC, PenObj, Player, SRCCOPY, TOO_MANY_PIXELS, crop, round_rect}; use crate::dib::{self, Rgba}; use crate::{Imported, Kind}; @@ -600,7 +600,7 @@ impl Pict { let base = Affine::translate(-frame.origin().to_vec2()); let artboard = Rect::new(0.0, 0.0, frame.width(), frame.height()); // A picture's coordinates map straight onto the document, as a WMF's do. - let p = Player::new(Kind::Wmf, base, 25.4 / 72.0, artboard); + let p = Player::new(Kind::Wmf, base, artboard); Self { p, frame, @@ -783,7 +783,7 @@ impl Pict { /// Fill `bp` (picture space) with `color`. fn fill(&mut self, mut bp: BezPath, color: [u8; 3], rule: FillRule) { bp.apply_affine(self.p.xf()); - self.p.dc.brush = BrushObj::Solid(color); + self.p.dc.brush = color; self.p.dc.fill_rule = rule; self.p.draw(bp, true, false); } diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs new file mode 100644 index 0000000000..d269fd27ca --- /dev/null +++ b/crates/metafile/src/import/picture.rs @@ -0,0 +1,523 @@ +//! EMF (with or without EMF+ records) and WMF files: `craft_metafile` plays the records into a +//! picture, and the picture's items become document objects here. +//! +//! - A filled or stroked path becomes a path with a fill and a stroke (caps, joins, miter limit +//! and dashes from the pen). Strokes drawn one after another, each starting where the last one +//! ended, with the same pen and clip, join into one path. +//! - A path filled with a pattern or hatched brush is filled with the average color of the +//! brush's tile. +//! - A bitmap becomes an image (its pixels as PNG) placed where the picture draws it, turned or +//! skewed with it. +//! - Text becomes point type in the font the file names, underlined or struck through as the font +//! is, and clipped to its rectangle when the file clips it. +//! - Clip regions and clip paths become clipping groups around what they clip; a rectangle around +//! the whole picture clips nothing and is left out. +//! +//! A WMF without a placeable header is read at 1440 units an inch, and one whose placeable box is +//! empty or inverted at the units an inch the header gives. An EMF without a usable frame is read +//! at its device's pixels a millimetre (96 pixels an inch when the header doesn't say). + +use std::collections::HashMap; +use std::sync::Arc; + +use craft_metafile::{Cap, ClipPath, HAlign, Item, Join, LineStyle, Seg, VAlign}; +use vectorcraft_color::Paint; +use vectorcraft_doc::clipnest::{Clip, Drawn, MAX_NEST, deep_clips, nest}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeId, + NodeKind, StrokeLayer, TextObject, +}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, shapes}; + +use super::{BAD_IMAGE, FAR_AWAY, HAIRLINE, LIMIT, MAX_NODES, around, rgb, sane}; +use crate::dib::Rgba; +use crate::wmf::PLACEABLE_KEY; +use crate::{Imported, Kind}; + +const PATTERNS: &str = "hatched and pattern brushes are filled with their average color"; +const ENDS_EARLY: &str = "the file ends early: what it held was imported"; + +/// Points a unit of a WMF without a placeable header (1440 units an inch). +const WMF_UNIT: f64 = 72.0 / 1440.0; +/// Points a millimetre. +const PT_PER_MM: f64 = 72.0 / 25.4; + +/// Points a picture unit across and down. `craft_metafile` gives a placeable WMF with a box and an +/// EMF with a frame in points, and other files in their logical (WMF) or device (EMF) units. +fn unit(bytes: &[u8], kind: Kind) -> (f64, f64) { + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let i16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| i16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let i32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let positive = |v: Option| v.filter(|v| *v > 0).map(f64::from); + match kind { + Kind::Wmf => { + if bytes.get(..4) != Some(&PLACEABLE_KEY.to_le_bytes()[..]) { + return (WMF_UNIT, WMF_UNIT); + } + if let (Some(l), Some(t), Some(r), Some(b)) = (i16_at(6), i16_at(8), i16_at(10), i16_at(12)) + && r > l + && b > t + { + return (1.0, 1.0); + } + let k = 72.0 / u16_at(14).filter(|v| *v > 0).map_or(1440.0, f64::from); + (k, k) + } + Kind::Emf => { + // Device pixels a millimetre on each axis: szlDevice over szlMicrometers when the header + // has them, else over szlMillimeters (both sizes must be positive). + let device = positive(i32_at(72)).zip(positive(i32_at(76))); + let micro = (u32_at(4).is_some_and(|s| s >= 108)) + .then(|| u32_at(100).zip(u32_at(104)).filter(|(w, h)| *w > 0 && *h > 0).map(|(w, h)| (f64::from(w) / 1000.0, f64::from(h) / 1000.0))) + .flatten(); + let mm = micro.or_else(|| positive(i32_at(80)).zip(positive(i32_at(84)))); + let px_mm = device.zip(mm).map(|((w, h), (mw, mh))| (w / mw, h / mh)); + let frame = matches!((i32_at(24), i32_at(28), i32_at(32), i32_at(36)), (Some(l), Some(t), Some(r), Some(b)) if r > l && b > t); + let ok = |v: f64| v.is_finite() && v > 0.0; + match px_mm { + Some(_) if frame => (1.0, 1.0), + Some((x, y)) if ok(x) && ok(y) => (PT_PER_MM / x, PT_PER_MM / y), + _ => (0.75, 0.75), + } + } + } +} + +/// What a stroke is drawn with, to tell whether two strokes join into one path. +#[derive(Clone, PartialEq)] +struct Pen { + color: [u8; 4], + width: f32, + line: LineStyle, + even_odd: bool, + chain: Vec, +} + +/// Strokes drawn one after another, not added yet: their pen, clips and path (document space). +struct Pending { + pen: Pen, + clips: Vec>, + path: BezPath, +} + +/// Clip paths already made, by address (the `Arc` keeps the address from being reused); None for +/// one that clips nothing in the picture. +type MadeClips = HashMap<*const ClipPath, (Arc, Option>)>; +/// The last clip region and the clip made for it. +type MadeRegion = (Vec<[f32; 4]>, Option>); + +struct Mapper { + /// Picture units → points. + xf: Affine, + /// How much lengths (stroke widths, dashes) grow from the picture to points. + k: f64, + frame: Rect, + doc: Document, + out: Drawn, + warnings: Vec, + next_clip: u32, + paths: MadeClips, + region: Option, + pending: Option, +} + +fn color(c: [u8; 4]) -> Paint { + rgb([c[0], c[1], c[2]]) +} + +/// The average color of a tile's pixels, weighted by their alpha, and their average alpha; None +/// for a clear tile. +fn average(rgba: &[u8]) -> Option<[u8; 4]> { + let pixels = rgba.as_chunks::<4>().0; + let mut sum = [0u64; 4]; + for p in pixels { + let a = u64::from(p[3]); + sum[0] += u64::from(p[0]) * a; + sum[1] += u64::from(p[1]) * a; + sum[2] += u64::from(p[2]) * a; + sum[3] += a; + } + let a = sum[3]; + let alpha = (a / pixels.len().max(1) as u64) as u8; + (a > 0).then(|| [(sum[0] / a) as u8, (sum[1] / a) as u8, (sum[2] / a) as u8, alpha.max(1)]) +} + +impl Mapper { + fn new(width: f64, height: f64, (kx, ky): (f64, f64)) -> Self { + let side = |v: f64| if v.is_finite() { v.clamp(1.0, LIMIT) } else { 1.0 }; + let (w, h) = (side(width * kx), side(height * ky)); + let mut doc = Document::new(w, h); + doc.layers.clear(); + Self { + xf: Affine::scale_non_uniform(kx, ky), + // The mean of the two, as the reader scales stroke lengths. + k: (kx + ky) / 2.0, + frame: Rect::new(0.0, 0.0, w, h), + doc, + out: vec![], + warnings: vec![], + next_clip: 0, + paths: HashMap::new(), + region: None, + pending: None, + } + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn path(&self, segs: &[Seg]) -> BezPath { + let mut bp = craft_metafile::bez_path(segs); + bp.apply_affine(self.xf); + bp + } + + /// A clip of `path` (document space), or None when it clips nothing in the picture. + fn clip(&mut self, path: BezPath, rule: FillRule) -> Option> { + if around(&path, self.frame) { + return None; + } + self.next_clip += 1; + Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })) + } + + /// The clips an item is drawn under, outermost first; None when it shows nowhere. + fn chain(&mut self, region: Option>, clips: &[Arc]) -> Option>> { + let mut chain = vec![]; + if let Some(rects) = region { + if rects.is_empty() { + return None; + } + let made = match &self.region { + Some((last, made)) if *last == rects => made.clone(), + _ => { + let mut bp = BezPath::new(); + for [x0, y0, x1, y1] in &rects { + bp.extend(shapes::rectangle(Rect::new(f64::from(*x0), f64::from(*y0), f64::from(*x1), f64::from(*y1))).to_bezpath()); + } + bp.apply_affine(self.xf); + if !sane(bp.bounding_box()) { + self.warn(FAR_AWAY); + return None; + } + let made = self.clip(bp, FillRule::NonZero); + self.region = Some((rects, made.clone())); + made + } + }; + chain.extend(made); + } + for c in clips { + let key = Arc::as_ptr(c); + let made = match self.paths.get(&key) { + Some((_, made)) => made.clone(), + None => { + let bp = self.path(&c.segs); + if !sane(bp.bounding_box()) { + self.warn(FAR_AWAY); + return None; + } + let made = self.clip(bp, if c.even_odd { FillRule::EvenOdd } else { FillRule::NonZero }); + self.paths.insert(key, (c.clone(), made.clone())); + made + } + }; + chain.extend(made); + } + if chain.len() > MAX_NEST { + chain.truncate(MAX_NEST); + self.warn(&deep_clips()); + } + Some(chain) + } + + fn push(&mut self, clips: Vec>, mut node: Node) { + if self.out.len() >= MAX_NODES { + return; + } + if !node.visual_bounds().is_none_or(sane) { + return self.warn(FAR_AWAY); + } + node.id = self.doc.alloc_id(); + self.out.push((clips, node)); + } + + /// Add the strokes waiting to be joined. + fn flush(&mut self) { + if let Some(p) = self.pending.take() { + let node = self.path_node(p.path, None, Some((p.pen.color, p.pen.width, &p.pen.line)), p.pen.even_odd); + if let Some(n) = node { + self.push(p.clips, n); + } + } + } + + fn stroke_layer(&self, c: [u8; 4], width: f32, line: &LineStyle) -> StrokeLayer { + let w = f64::from(width) * self.k; + let mut st = StrokeLayer::new(color(c), if w > 0.0 && w.is_finite() { w } else { HAIRLINE }); + st.opacity = f32::from(c[3]) / 255.0; + st.cap = match line.cap { + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + Cap::Flat => LineCap::Butt, + }; + st.join = match line.join { + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + Join::Miter => LineJoin::Miter, + }; + let miter = f64::from(line.miter_limit); + if miter.is_finite() && miter >= 1.0 { + st.miter_limit = miter; + } + let dash = Dash { pattern: line.dash.iter().map(|d| f64::from(*d) * self.k).collect(), offset: 0.0, align_corners: false }; + st.dash = dash.is_dashed().then_some(dash); + st + } + + /// A path node painted with `fill` and `stroke`; None when neither shows. + fn path_node(&self, bp: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f32, &LineStyle)>, even_odd: bool) -> Option { + if bp.elements().is_empty() { + return None; + } + let mut items = vec![]; + if let Some(c) = fill.filter(|c| c[3] > 0) { + let mut f = FillLayer::new(color(c)); + f.opacity = f32::from(c[3]) / 255.0; + items.push(AppearanceItem::Fill(f)); + } + if let Some((c, w, line)) = stroke.filter(|s| s.0[3] > 0) { + items.push(AppearanceItem::Stroke(self.stroke_layer(c, w, line))); + } + if items.is_empty() { + return None; + } + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items, ..Appearance::default() }); + if let NodeKind::Path { rule, .. } = &mut n.kind { + *rule = if even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; + } + Some(n) + } + + fn item(&mut self, item: Item) { + match item { + Item::Path { segs, fill, stroke, line, even_odd, region, clips } => { + let Some(chain) = self.chain(region, &clips) else { return self.flush() }; + let bp = self.path(&segs); + // An open stroke with nothing filled waits, to join the next one. + if let (None, Some((c, w))) = (fill, stroke) + && !segs.iter().any(|s| matches!(s, Seg::Close)) + { + let pen = Pen { color: c, width: w, line, even_odd, chain: chain.iter().map(|c| c.id).collect() }; + let start = bp.elements().first().and_then(|e| match e { + PathEl::MoveTo(p) => Some(*p), + _ => None, + }); + if let Some(p) = &mut self.pending + && p.pen == pen + && start.is_some() + && p.path.elements().last().and_then(|e| e.end_point()) == start + { + p.path.extend(bp.elements().iter().skip(1).copied()); + return; + } + self.flush(); + self.pending = Some(Pending { pen, clips: chain, path: bp }); + return; + } + self.flush(); + let stroke = stroke.map(|(c, w)| (c, w, &line)); + if let Some(n) = self.path_node(bp, fill, stroke, even_odd) { + self.push(chain, n); + } + } + Item::Pattern { segs, even_odd, tile, region, clips } => { + self.flush(); + let Some(chain) = self.chain(region, &clips) else { return }; + let Some(c) = average(&tile.rgba) else { return }; + self.warn(PATTERNS); + let bp = self.path(&segs); + if let Some(n) = self.path_node(bp, Some(c), None, even_odd) { + self.push(chain, n); + } + } + Item::Bitmap { transform, width, height, rgba, region, clips, .. } => { + self.flush(); + let Some(chain) = self.chain(region, &clips) else { return }; + self.image(Rgba { width, height, pixels: rgba }, Affine::new(transform), chain); + } + Item::Text(t) => { + self.flush(); + let Some(mut chain) = self.chain(t.region.clone(), &t.clips) else { return }; + // The text's own clip rectangle, inside the others. + if let Some([x0, y0, x1, y1]) = t.clip { + let mut bp = shapes::rectangle(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1)).abs()).to_bezpath(); + bp.apply_affine(self.xf); + if !sane(bp.bounding_box()) { + return self.warn(FAR_AWAY); + } + if let Some(own) = self.clip(bp, FillRule::NonZero) { + // Past the stack, the text's own clip takes the innermost place kept. + if chain.len() >= MAX_NEST { + chain.truncate(MAX_NEST.saturating_sub(1)); + self.warn(&deep_clips()); + } + chain.push(own); + } + } + self.text(&t, chain); + } + } + } + + /// An image whose unit square `unit` places in the picture. + fn image(&mut self, img: Rgba, unit: Affine, chain: Vec>) { + if img.width == 0 || img.height == 0 { + return; + } + let xf = self.xf * unit * Affine::scale_non_uniform(1.0 / f64::from(img.width), 1.0 / f64::from(img.height)); + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return; + } + let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) }; + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; + self.push(chain, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); + } + + fn text(&mut self, t: &craft_metafile::Text, chain: Vec>) { + let s: String = t.text.chars().filter(|c| *c != '\0').collect(); + if s.trim().is_empty() || t.color[3] == 0 { + return; + } + // Text space → document without the move: its vertical scale gives the type size, and what + // is left of it (a turn, a stretch across) goes on the text object. + let [a, b, c, d, ..] = t.transform; + let linear = self.xf * Affine::new([a, b, c, d, 0.0, 0.0]); + let [_, _, c, d, ..] = linear.as_coeffs(); + let k = c.hypot(d); + let shape = if k > 1e-12 && k.is_finite() && linear.determinant().abs() > 1e-24 { Affine::scale(1.0 / k) * linear } else { Affine::IDENTITY }; + let f = &t.font; + let h = f64::from(f.height); + // A negative height is the em; a positive one the cell (the em and the internal leading). + let em = if h < 0.0 { -h } else { h * 0.85 }; + let size = if em > 0.0 && (em * k).is_finite() { (em * k).clamp(0.5, 5000.0) } else { 12.0 }; + let mut style = CharStyle { size, fill: color(t.color), stroke: Paint::None, ..CharStyle::default() }; + if !f.family.trim().is_empty() { + style.font_family = f.family.trim().to_string(); + } + style.font_style = match (f.weight >= 600, f.italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + } + .into(); + style.underline = f.underline; + style.strikethrough = f.strike; + // Where the baseline is below the reference point. + let drop = match t.v_align { + VAlign::Baseline => 0.0, + VAlign::Bottom => -0.2 * size, + VAlign::Top => 0.8 * size, + }; + let origin = self.xf * Point::new(t.transform[4], t.transform[5]); + let mut text = TextObject::point(Point::ZERO, &s, style); + text.xf = Affine::translate(origin.to_vec2()) * shape * Affine::translate((0.0, drop)); + text.para.justify = match t.h_align { + HAlign::Center => Justify::Center, + HAlign::Right => Justify::Right, + HAlign::Left => Justify::Left, + }; + text.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text).bounds); + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(text))); + n.opacity = f32::from(t.color[3]) / 255.0; + self.push(chain, n); + } + + /// The document: what was drawn on one layer, clipped art in clipping groups. + fn finish(mut self, kind: Kind, skipped: u32) -> Imported { + self.flush(); + if skipped > 0 { + let n = skipped; + self.warnings.push(format!( + "{n} record{} of kinds VectorCraft doesn't read {} skipped", + if n == 1 { "" } else { "s" }, + if n == 1 { "was" } else { "were" } + )); + } + let out = std::mem::take(&mut self.out); + let children = nest(&mut self.doc, out); + let mut layer = Node::layer(self.doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); + if let Some(c) = layer.children_mut() { + *c = children; + } + self.doc.layers = vec![Arc::new(layer)]; + Imported { document: self.doc, kind, warnings: self.warnings } + } +} + +/// Does the file stop before its end-of-file record? +fn ends_early(bytes: &[u8], kind: Kind) -> bool { + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let mut at = match kind { + Kind::Emf => 0, + Kind::Wmf => { + let start = if u32_at(0) == Some(PLACEABLE_KEY) { 22 } else { 0 }; + let Some(words) = u16_at(start + 2) else { return true }; + start + usize::from(words) * 2 + } + }; + loop { + let size = match kind { + Kind::Emf => { + let (Some(kind), Some(size)) = (u32_at(at), u32_at(at.saturating_add(4))) else { return true }; + if kind == 14 { + return false; + } + // Records are a multiple of 4 bytes long; reading stops at one that isn't. + if !size.is_multiple_of(4) { + return true; + } + size as usize + } + Kind::Wmf => { + let (Some(words), Some(function)) = (u32_at(at), u16_at(at.saturating_add(4))) else { return true }; + if function == 0 { + return false; + } + (words as usize).saturating_mul(2) + } + }; + if size < 6 { + return true; + } + at = at.saturating_add(size); + } +} + +/// Read an EMF or WMF file. +pub(crate) fn import(bytes: &[u8]) -> Result { + let kind = crate::sniff(bytes).ok_or("not an EMF or WMF file")?; + let pic = craft_metafile::parse(bytes).map_err(|e| match e { + craft_metafile::Error::NotMetafile => "not an EMF or WMF file".to_string(), + craft_metafile::Error::BadHeader => format!("the {} header is damaged", kind.id().to_uppercase()), + craft_metafile::Error::NoRecords => format!("the {} file holds no records", kind.id().to_uppercase()), + })?; + let k = unit(bytes, kind); + let mut m = Mapper::new(f64::from(pic.width), f64::from(pic.height), k); + if ends_early(bytes, kind) { + m.warn(ENDS_EARLY); + } + for item in pic.items { + m.item(item); + } + Ok(m.finish(kind, pic.skipped_records)) +} diff --git a/crates/metafile/src/import/wmf.rs b/crates/metafile/src/import/wmf.rs deleted file mode 100644 index 7a682e59a9..0000000000 --- a/crates/metafile/src/import/wmf.rs +++ /dev/null @@ -1,338 +0,0 @@ -//! WMF records (MS-WMF) played on a [`Player`]. A placeable header gives the picture's box in -//! logical units and the units an inch; without one, the first window origin and extent are the -//! box, at 1440 units an inch. - -use vectorcraft_geom::{Affine, Point, Rect, shapes}; - -use super::{ArcKind, BrushObj, FontObj, Obj, PenObj, Player}; -use crate::bytes::Reader; -use crate::dib::{self, Alpha}; -use crate::wmf::PLACEABLE_KEY; -use crate::{Imported, Kind}; - -/// Units an inch when the file doesn't say (twips). -const DEFAULT_INCH: f64 = 1440.0; -/// Records that change nothing VectorCraft keeps (background, raster and mapping details, palettes, -/// escapes). -const IGNORED: &[u16] = &[ - 0x0102, 0x0103, 0x0104, 0x0105, 0x0107, 0x0108, 0x0201, 0x020A, 0x020B, 0x020C, 0x020D, 0x020E, 0x020F, 0x0211, 0x0410, 0x0412, 0x0231, 0x0234, - 0x0035, 0x0037, 0x0139, 0x0436, 0x0626, -]; - -fn point(r: &mut Reader) -> Option { - Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?))) -} - -/// A point stored y first. -fn point_yx(r: &mut Reader) -> Option { - let y = f64::from(r.i16()?); - Some(Point::new(f64::from(r.i16()?), y)) -} - -/// A rectangle stored bottom, right, top, left. -fn rect_brtl(r: &mut Reader) -> Option { - let (b, rt, t, l) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - Some(Rect::new(f64::from(l), f64::from(t), f64::from(rt), f64::from(b))) -} - -fn color(r: &mut Reader) -> Option<[u8; 3]> { - let [red, g, b, _] = r.u32()?.to_le_bytes(); - Some([red, g, b]) -} - -fn points(r: &mut Reader, n: usize) -> Option> { - if r.rest().len() < n * 4 { - return None; - } - (0..n).map(|_| point(r)).collect() -} - -/// ANSI text (read as Latin-1). -fn ansi(b: &[u8]) -> String { - b.iter().map(|c| char::from(*c)).collect() -} - -/// The records' start, and the box and units an inch of a placeable header. -fn header(bytes: &[u8]) -> Option<(usize, Option<(Rect, f64)>)> { - let mut r = Reader::new(bytes); - let placeable = if r.u32()? == PLACEABLE_KEY { - r.skip(2)?; - let (l, t, rt, b) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - let inch = r.u16()?; - let inch = if inch == 0 { DEFAULT_INCH } else { f64::from(inch) }; - Some((Rect::new(f64::from(l), f64::from(t), f64::from(rt), f64::from(b)).abs(), inch)) - } else { - None - }; - let start = if placeable.is_some() { 22 } else { 0 }; - let mut h = Reader::new(bytes.get(start..)?); - h.skip(2)?; - let words = usize::from(h.u16()?); - if words < 9 { - return None; - } - Some((start + words * 2, placeable)) -} - -/// The first window origin and extent, as the picture's box when there is no placeable header. -fn window(bytes: &[u8], mut at: usize) -> Option { - let (mut org, mut ext) = (None, None); - while let Some(head) = bytes.get(at..at + 6) { - let words = u32::from_le_bytes([head[0], head[1], head[2], head[3]]) as usize; - let function = u16::from_le_bytes([head[4], head[5]]); - if words < 3 || function == 0 { - break; - } - let mut r = Reader::new(bytes.get(at + 6..).unwrap_or_default()); - match function { - 0x020B if org.is_none() => org = point_yx(&mut r), - 0x020C if ext.is_none() => ext = point_yx(&mut r), - _ => {} - } - if org.is_some() && ext.is_some() { - break; - } - at = at.saturating_add(words.saturating_mul(2)); - } - let o = org.unwrap_or(Point::ZERO); - let e = ext?; - Some(Rect::new(o.x, o.y, o.x + e.x, o.y + e.y).abs()) -} - -/// Logical → document for a placeable file whose window (`org`, `ext`) is mapped onto its box `b`; -/// a negative extent flips that axis, as in GDI. -fn window_map(b: Rect, k: f64, org: Point, ext: Point) -> Affine { - let ratio = |box_len: f64, e: f64| if e.abs() > 1e-12 { box_len / e } else { 1.0 }; - Affine::scale_non_uniform(k * ratio(b.width(), ext.x), k * ratio(b.height(), ext.y)) * Affine::translate(-org.to_vec2()) -} - -pub(super) fn play(bytes: &[u8]) -> Result { - let (start, placeable) = header(bytes).ok_or("the WMF header is damaged")?; - let (frame, inch) = match placeable { - Some((b, inch)) if b.width() > 0.0 && b.height() > 0.0 => (Some(b), inch), - _ => (window(bytes, start).filter(|w| w.width() > 0.0 && w.height() > 0.0), DEFAULT_INCH), - }; - let k = 72.0 / inch; - let origin = frame.map_or(Point::ZERO, |f| f.origin()); - let artboard = frame.map_or(Rect::new(0.0, 0.0, 1.0, 1.0), |f| Rect::new(0.0, 0.0, f.width() * k, f.height() * k)); - let mut p = Player::new(Kind::Wmf, Affine::scale(k) * Affine::translate(-origin.to_vec2()), 25.4 / inch, artboard); - // With a placeable header, the window records map the window onto the box. - let placed = placeable.map(|(b, _)| b).filter(|b| b.width() > 0.0 && b.height() > 0.0); - let mut win = (placed.map_or(Point::ZERO, |b| b.origin()), placed.map_or(Point::ZERO, |b| Point::new(b.width(), b.height()))); - let mut at = start; - let mut ended = false; - while let Some(head) = bytes.get(at..at + 6) { - let words = u32::from_le_bytes([head[0], head[1], head[2], head[3]]) as usize; - let function = u16::from_le_bytes([head[4], head[5]]); - if function == 0 { - ended = true; - break; - } - let Some(rec) = words.checked_mul(2).and_then(|n| bytes.get(at..at.checked_add(n)?)).filter(|_| words >= 3) else { break }; - if !matches!(function, 0x0213 | 0x0214) { - p.flush(); - } - if let (Some(b), 0x020B | 0x020C) = (placed, function) { - if let Some(v) = point_yx(&mut Reader::new(rec.get(6..).unwrap_or_default())) { - if function == 0x020B { - win.0 = v; - } else { - win.1 = v; - } - p.dev_to_doc = window_map(b, k, win.0, win.1); - } - } else if record(&mut p, function, rec.get(6..).unwrap_or_default()).is_none() { - p.skip(); - } - at += rec.len(); - } - if !ended { - p.warn("the file ends early: what it held was imported"); - } - let mut out = p.finish(); - // No box given: the art's bounds. - if frame.is_none() - && let Some(b) = out.document.layers.first().and_then(|l| vectorcraft_doc::live::nodes_bounds(l.children().map_or(&[][..], |c| c.as_slice()))) - && let Some(a) = out.document.artboards.first_mut() - { - a.rect = Rect::from_origin_size(b.origin(), (b.width().max(1.0), b.height().max(1.0))); - } - Ok(out) -} - -/// An image record's source rectangle (from the bottom for a bottom-up DIB) and destination. -fn blit(p: &mut Player, rop: u32, src: (i16, i16, i16, i16), dest: (i16, i16, i16, i16), packed: Option<&[u8]>) { - let (xd, yd, wd, hd) = (f64::from(dest.0), f64::from(dest.1), f64::from(dest.2), f64::from(dest.3)); - let d = Rect::new(xd, yd, xd + wd, yd + hd); - let Some(packed) = packed else { return p.pattern_blit(d, rop) }; - let parts = dib::split(packed); - let img = parts.ok_or_else(|| "damaged".to_string()).and_then(|(bmi, bits)| dib::decode(bmi, bits, Alpha::Ignore, &mut p.pixels_left)); - let bottom_up = parts.and_then(|(bmi, _)| bmi.get(8..12)).is_some_and(|h| i32::from_le_bytes([h[0], h[1], h[2], h[3]]) > 0); - let ih = img.as_ref().map_or(0.0, |i| f64::from(i.height)); - let (xs, ys, ws, hs) = (f64::from(src.0), f64::from(src.1), f64::from(src.2), f64::from(src.3)); - let top = if bottom_up { ih - ys - hs } else { ys }; - p.image(img, Rect::new(xs, top, xs + ws, top + hs), d, rop, 1.0); -} - -/// PolyPolygon (0x0538). -fn rec_poly_polygon(p: &mut Player, r: &mut Reader) -> Option<()> { - let n = usize::from(r.u16()?); - if r.rest().len() < n * 2 { - return None; - } - let counts: Vec = (0..n).map(|_| r.u16().map(usize::from)).collect::>()?; - let mut polys = Vec::with_capacity(n); - for c in counts { - polys.push(points(r, c)?); - } - p.poly_poly(&polys, true); - Some(()) -} - -/// Arc (0x0817), Pie (0x081A) and Chord (0x0830). -fn rec_arc(p: &mut Player, function: u16, r: &mut Reader) -> Option<()> { - let (end, start) = (point_yx(r)?, point_yx(r)?); - let b = rect_brtl(r)?; - let kind = match function { - 0x0817 => ArcKind::Open, - 0x081A => ArcKind::Pie, - _ => ArcKind::Chord, - }; - p.arc(b, start, end, kind); - Some(()) -} - -/// CreateFontIndirect (0x02FB). -fn rec_create_font(p: &mut Player, r: &mut Reader) -> Option<()> { - let height = f64::from(r.i16()?); - r.skip(2)?; - let escapement = f64::from(r.i16()?); - r.skip(2)?; - let weight = i32::from(r.i16()?); - let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0); - r.skip(7)?; - let face: Vec = r.rest().iter().take(32).take_while(|b| **b != 0).copied().collect(); - p.create(Obj::Font(FontObj { height, escapement, weight, italic, face: ansi(&face) })); - Some(()) -} - -/// TextOut (0x0521). -fn rec_text_out(p: &mut Player, r: &mut Reader) -> Option<()> { - let n = usize::try_from(r.i16()?).ok()?; - let s = ansi(r.bytes(n)?); - r.skip(n % 2)?; - let at = point_yx(r)?; - p.text(at, &s); - Some(()) -} - -/// ExtTextOut (0x0A32). -fn rec_ext_text_out(p: &mut Player, r: &mut Reader) -> Option<()> { - let at = point_yx(r)?; - let n = usize::try_from(r.i16()?).ok()?; - let opts = r.u16()?; - // ETO_OPAQUE or ETO_CLIPPED: a rectangle comes first. - if opts & 0x6 != 0 { - r.skip(8)?; - } - let s = ansi(r.bytes(n)?); - p.text(at, &s); - Some(()) -} - -/// StretchDIB (0x0F43). -fn rec_stretch_dib(p: &mut Player, r: &mut Reader) -> Option<()> { - let rop = r.u32()?; - r.skip(2)?; - let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), Some(r.rest())); - Some(()) -} - -/// StretchBlt (0x0B41). -fn rec_stretch_blt(p: &mut Player, body: &[u8], r: &mut Reader) -> Option<()> { - let rop = r.u32()?; - let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - // Without a bitmap, a reserved word comes before the destination. - let with_bitmap = body.len() > 22; - if !with_bitmap { - r.skip(2)?; - } - let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), with_bitmap.then(|| r.rest())); - Some(()) -} - -/// BitBlt (0x0940). -fn rec_bit_blt(p: &mut Player, body: &[u8], r: &mut Reader) -> Option<()> { - let rop = r.u32()?; - let (ys, xs) = (r.i16()?, r.i16()?); - let with_bitmap = body.len() > 20; - if !with_bitmap { - r.skip(2)?; - } - let (h, w, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, w, h), (xd, yd, w, h), with_bitmap.then(|| r.rest())); - Some(()) -} - -/// Play one record (`r`: its parameters). `None`: unknown or damaged. -fn record(p: &mut Player, function: u16, body: &[u8]) -> Option<()> { - let mut r = Reader::new(body); - match function { - 0x001E => p.save(), - 0x0127 => p.restore(i32::from(r.i16()?)), - 0x0106 => p.set_fill_mode(r.u16()? == 1), - 0x0209 => p.set_text_color(color(&mut r)?), - 0x012E => p.set_text_align(u32::from(r.u16()?)), - 0x0214 => p.move_to(point_yx(&mut r)?), - 0x0213 => { - let pt = point_yx(&mut r)?; - p.poly_to(&[pt], false); - } - 0x0324 | 0x0325 => { - let n = usize::try_from(r.i16()?).ok()?; - let pts = points(&mut r, n)?; - p.poly(&pts, function == 0x0324, false); - } - 0x0538 => rec_poly_polygon(p, &mut r)?, - 0x041B => p.figure(shapes::rectangle(rect_brtl(&mut r)?.abs()).to_bezpath(), true), - 0x0418 => p.figure(shapes::ellipse(rect_brtl(&mut r)?.abs()).to_bezpath(), true), - 0x061C => { - let (h, w) = (f64::from(r.i16()?), f64::from(r.i16()?)); - p.figure(super::round_rect(rect_brtl(&mut r)?, w, h), true); - } - 0x0817 | 0x081A | 0x0830 => rec_arc(p, function, &mut r)?, - 0x0416 => p.clip_rect(rect_brtl(&mut r)?), - 0x0415 => p.exclude_clip(), - 0x012D => p.select(u32::from(r.u16()?)), - 0x01F0 => p.delete(u32::from(r.u16()?)), - 0x02FA => { - let style = u32::from(r.u16()?); - let width = f64::from(r.i16()?); - r.skip(2)?; - p.create(Obj::Pen(PenObj { style, width, color: color(&mut r)?, entries: vec![] })); - } - 0x02FC => { - let style = u32::from(r.u16()?); - p.create(Obj::Brush(BrushObj::from_log(style, color(&mut r)?))); - } - 0x02FB => rec_create_font(p, &mut r)?, - 0x00F7 | 0x06FF => p.create(Obj::Other), - 0x01F9 | 0x0142 => p.create(Obj::Brush(BrushObj::Pattern)), - 0x0521 => rec_text_out(p, &mut r)?, - 0x0A32 => rec_ext_text_out(p, &mut r)?, - 0x0F43 => rec_stretch_dib(p, &mut r)?, - 0x0B41 => rec_stretch_blt(p, body, &mut r)?, - 0x0940 => rec_bit_blt(p, body, &mut r)?, - 0x061D => { - let rop = r.u32()?; - let (h, w, y, x) = (f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?)); - p.pattern_blit(Rect::new(x, y, x + w, y + h), rop); - } - f if IGNORED.contains(&f) => {} - _ => return None, - } - Some(()) -} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 0e699236d7..86d268cee1 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -13,13 +13,14 @@ //! lines or gradients, so clipped art is written whole, images over white, dashes as separate //! lines and gradients and patterns as one colour (each loss comes back as a warning). //! -//! [`import`] reads either format back into a document of one artboard (the picture's frame) and -//! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are -//! skipped, with one warning. [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2) -//! into the same kind of document. +//! [`import`] reads either format (an EMF with its EMF+ records too, through `craft_metafile`) +//! into a document of one artboard (the picture's frame) and one layer: paths with fills and +//! strokes, clipping, images and text. Records it doesn't read are skipped, with one warning. +//! [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2) into the same kind of +//! document. //! -//! - `bytes`: little-endian writing and bounds-checked reading. -//! - `dib`: device-independent bitmaps, both ways. +//! - `bytes`: little-endian writing. +//! - `dib`: device-independent bitmaps for the writers. //! - `scene`: the document walk both writers share. //! - `emf`, `wmf`: the writers. //! - `import`: the readers. diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 1e0ec5671b..689037d277 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -151,13 +151,14 @@ fn dashes_gradients_and_images_round_trip_through_emf() { // ---------- hand-built records ---------- /// An EMF of `records` (type, body) with a frame of `w` × `h` hundredths of a millimetre on a -/// device of 100 pixels a millimetre. +/// device of 100 pixels a millimetre. Bodies are padded to a multiple of 4 bytes. fn hand_emf(w: i32, h: i32, records: &[(u32, Vec)]) -> Vec { let mut o = Out::default(); let rec = |o: &mut Out, kind: u32, body: &[u8]| { o.u32(kind); - o.u32(8 + body.len() as u32); + o.u32(8 + body.len().next_multiple_of(4) as u32); o.bytes(body); + o.pad(4); }; let mut head = Out::default(); head.bytes(&[0; 16]); @@ -336,6 +337,18 @@ fn wmf_window_is_mapped_onto_the_placeable_box() { } } +#[test] +fn wmf_without_a_placeable_header_is_read_at_1440_units_an_inch() { + // The window is 2880 × 1440 units: 2 × 1 inches. The rectangle is its left half. + let records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 2880])), (0x041B, le16(&[1440, 1440, 0, 0]))]; + let plain = hand_wmf(2880, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 144.0, 72.0), 1e-6), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 72.0), 1e-6), "{:?}", p[0].geometric_bounds()); +} + #[test] fn damaged_files_fail_or_import_partly() { let good = emf(&sample()).bytes; @@ -371,19 +384,29 @@ fn png_stretch_dibits(png: &[u8]) -> Vec { } #[test] -fn embedded_bitmaps_share_one_pixel_budget() { - // #3600: each bitmap was bounded but not their total, so many records of the largest one a record - // may hold cost minutes. Those past the file's budget are left out before they're decoded. +fn oversized_embedded_bitmaps_are_left_out_before_decoding() { + // #3600: many records of the largest bitmap a record may hold cost minutes. Each of these claims + // more pixels than one image may have, so the reader leaves it out from its header. (The reader's + // budget for all the images of one file is tested where the reader is.) let records = vec![(81, png_stretch_dibits(&png_claiming(1 << 13, 1 << 13))); 5]; let back = import(&hand_emf(5000, 3000, &records)).unwrap(); - assert!(back.warnings.iter().any(|w| w == "images past the most pixels one file may hold were left out"), "{:?}", back.warnings); + assert!(!all(&back.document).iter().any(|n| matches!(n.kind, NodeKind::Image(_)))); } #[test] fn clips_nest_only_so_deep() { - // Each clip inside the last one used to nest a clipping group one level deeper, past the stack. - let mut records = vec![(30, le(&[0, 0, 1500, 1500])); 200_000]; - records.push((43, le(&[100, 100, 200, 200]))); + // Each clip inside the last one nests a clipping group one level deeper; past the stack they + // are left out with a warning. + let mut records = vec![]; + // As many as the reader keeps: one more than the stack. + for i in 0..32 { + // BEGINPATH, a rectangle a little smaller each time, ENDPATH, SELECTCLIPPATH (RGN_AND). + records.push((59, vec![])); + records.push((43, le(&[100 + i, 100 + i, 2000 - i, 2000 - i]))); + records.push((60, vec![])); + records.push((67, le(&[1]))); + } + records.push((43, le(&[500, 500, 600, 600]))); let back = import(&hand_emf(5000, 3000, &records)).unwrap(); let mut depth = 0; let mut n = back.document.layers[0].children().unwrap()[0].clone(); @@ -393,4 +416,267 @@ fn clips_nest_only_so_deep() { } assert_eq!(depth, vectorcraft_doc::clipnest::MAX_NEST); assert!(back.warnings.iter().any(|w| w.contains("clips nested more than")), "{:?}", back.warnings); + // A hostile count of clip records: each one used to nest a clipping group one level deeper, + // past the stack. They make one clip. + let mut records = vec![(30, le(&[0, 0, 1500, 1500])); 200_000]; + records.push((43, le(&[100, 100, 200, 200]))); + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + assert!(children.iter().all(|c| !matches!(c.kind, NodeKind::Group { .. })), "one level"); + assert_eq!(paths(&back.document).len(), 1); +} + +/// An EMR_COMMENT holding EMF+ `records` (type, flags, data). +fn emf_plus(records: &[(u16, u16, Vec)]) -> Vec { + let mut plus = Out::default(); + for (kind, flags, data) in records { + plus.u16(*kind); + plus.u16(*flags); + plus.u32(12 + data.len() as u32); + plus.u32(data.len() as u32); + plus.bytes(data); + } + let mut body = Out::default(); + body.u32(4 + plus.len() as u32); + // "EMF+" + body.u32(0x2B46_4D45); + body.bytes(&plus.0); + body.0 +} + +fn le_f32(values: &[f32]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +#[test] +fn emf_plus_only_pictures_import_their_drawing() { + // An EMF+ only file: its EMF records draw nothing, so it was imported empty with a warning. + // EmfPlusHeader (version, flags, 96 dpi), FillRects with an inline green color (flag S) and one + // rectangle of 10 × 5 mm at (10, 10) mm in device pixels, EmfPlusEndOfFile. + let header = le(&[0xDBC0_1002u32 as i32, 0, 96, 96]); + let rects = [le(&[0xFF00_FF00u32 as i32, 1]), le_f32(&[1000.0, 1000.0, 1000.0, 500.0])].concat(); + let comment = emf_plus(&[(0x4001, 0, header), (0x400A, 0x8000, rects), (0x4002, 0, vec![])]); + let back = import(&hand_emf(5000, 3000, &[(70, comment)])).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert_eq!(fill_rgb(&p[0]), Some([0, 255, 0])); + let mm = 72.0 / 25.4; + let b = p[0].geometric_bounds().unwrap(); + assert!(near(b, Rect::new(10.0 * mm, 10.0 * mm, 20.0 * mm, 15.0 * mm), 0.01), "{b:?}"); +} + +#[test] +fn hatched_brushes_fill_with_their_average_color() { + // CREATEBRUSHINDIRECT: a hatched (style 2) blue brush, selected; a null pen; a rectangle. The + // hatch is drawn over the white background, so the average is a light blue. + let records = vec![(39, le(&[1, 2, 0x00ff_0000, 0])), (37, le(&[1])), (37, le(&[0x8000_0008u32 as i32])), (43, le(&[1000, 1000, 2000, 1500]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + assert_eq!(back.warnings, vec!["hatched and pattern brushes are filled with their average color".to_string()]); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + let [r, g, b] = fill_rgb(&p[0]).unwrap(); + assert!(b == 255 && r == g && r > 0 && r < 255, "{:?}", [r, g, b]); + // With a transparent background (SETBKMODE 1) the hatch is blue with gaps: a translucent fill. + let mut records = records; + records.insert(0, (18, le(&[1]))); + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let p = paths(&back.document); + assert_eq!(fill_rgb(&p[0]), Some([0, 0, 255])); + let AppearanceItem::Fill(f) = &p[0].appearance.items[0] else { panic!("a fill") }; + assert!(f.opacity > 0.0 && f.opacity < 1.0, "{}", f.opacity); +} + +#[test] +fn text_keeps_a_stretch_across() { + // A world transform twice as wide as it is high, a 10 mm font and "Hi". + let mut text = vec![0; 16]; + text.extend(le(&[1, 0, 0])); + text.extend(le(&[1000, 1500, 2, 76, 0, 0, 0, 0, 0, 0])); + text.extend("Hi".encode_utf16().flat_map(u16::to_le_bytes)); + let mut font = le(&[2, -1000, 0, 0, 0, 400]); + font.extend([0u8; 8]); + font.extend("Arial".encode_utf16().chain(std::iter::repeat_n(0, 27)).flat_map(u16::to_le_bytes)); + let world = [2.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect(); + let records = vec![(35, world), (82, font), (37, le(&[2])), (84, text)]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let t = all(&back.document) + .into_iter() + .find_map(|n| match n.kind { + NodeKind::Text(t) => Some(t), + _ => None, + }) + .unwrap(); + let mm = 72.0 / 25.4; + assert!((t.runs[0].style.size - 10.0 * mm).abs() < 0.01, "{}", t.runs[0].style.size); + let [a, b, c, d, ..] = t.xf.as_coeffs(); + assert!((a - 2.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6, "{:?}", t.xf); +} + +#[test] +fn a_clip_whose_box_holds_the_picture_still_clips() { + // A diamond around the middle of the picture, whose box holds the whole frame but which cuts + // off its corners: BEGINPATH, POLYGON, ENDPATH, SELECTCLIPPATH (RGN_COPY), then a rectangle. + let mut diamond = le(&[0, 0, 0, 0, 4]); + diamond.extend(le(&[2500, -500, 5500, 1500, 2500, 3500, -500, 1500])); + // A bow tie through the four corners of a box around the picture. + let mut bow_tie = le(&[0, 0, 0, 0, 4]); + bow_tie.extend(le(&[-500, -500, 5500, 3500, 5500, -500, -500, 3500])); + for clip in [diamond, bow_tie] { + let records = vec![(59, vec![]), (3, clip), (60, vec![]), (67, le(&[5])), (43, le(&[0, 0, 5000, 3000]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + assert!(matches!(layer[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", layer[0].kind); + } +} + +#[test] +fn dual_emf_plus_files_draw_their_emf_plus_records() { + // A dual file (header flag 1): a translucent red EMF+ fill, and a plain rectangle that is only + // the fallback for readers of plain records. + let header = le(&[0xDBC0_1002u32 as i32, 0, 96, 96]); + let rects = [le(&[0x80FF_0000u32 as i32, 1]), le_f32(&[1000.0, 1000.0, 1000.0, 500.0])].concat(); + let comment = emf_plus(&[(0x4001, 1, header), (0x400A, 0x8000, rects), (0x4002, 0, vec![])]); + let back = import(&hand_emf(5000, 3000, &[(70, comment), (43, le(&[3000, 1000, 4000, 2000]))])).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert_eq!(fill_rgb(&p[0]), Some([255, 0, 0])); + let AppearanceItem::Fill(f) = &p[0].appearance.items[0] else { panic!("a fill") }; + assert!((f.opacity - 128.0 / 255.0).abs() < 1e-6, "{}", f.opacity); +} + +#[test] +fn pattern_brushes_fill_with_their_average_color() { + // CREATEDIBPATTERNBRUSHPT: a 2 × 2 tile of 24-bit pixels, two red and two blue (bottom-up rows + // padded to 4 bytes); selected, a null pen, a rectangle. + let mut brush = le(&[1, 0, 32, 40, 72, 16]); + brush.extend(le(&[40, 2, 2, 0x0018_0001, 0, 0, 0, 0, 0, 0])); + brush.extend([0, 0, 255, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0]); + let records = vec![(94, brush), (37, le(&[1])), (37, le(&[0x8000_0008u32 as i32])), (43, le(&[1000, 1000, 2000, 1500]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + assert_eq!(back.warnings, vec!["hatched and pattern brushes are filled with their average color".to_string()]); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + let [r, g, b] = fill_rgb(&p[0]).unwrap(); + assert!((126..=128).contains(&r) && g == 0 && (126..=128).contains(&b), "{:?}", [r, g, b]); +} + +#[test] +fn clipped_text_is_clipped_to_its_rectangle() { + // EXTTEXTOUTW with ETO_CLIPPED and a rectangle of 9–12 × 10–16 mm, in an underlined and struck + // through font. + let mut text = vec![0; 16]; + text.extend(le(&[1, 0, 0])); + text.extend(le(&[1000, 1500, 2, 76, 4, 900, 1000, 1200, 1600, 0])); + text.extend("Hi".encode_utf16().flat_map(u16::to_le_bytes)); + let mut font = le(&[2, -1000, 0, 0, 0, 400]); + font.extend([0, 1, 1, 0, 0, 0, 0, 0]); + font.extend("Arial".encode_utf16().chain(std::iter::repeat_n(0, 27)).flat_map(u16::to_le_bytes)); + let mut records = vec![(82, font), (37, le(&[2])), (84, text)]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + let mm = 72.0 / 25.4; + let own = Rect::new(9.0 * mm, 10.0 * mm, 12.0 * mm, 16.0 * mm); + assert!(near(children[0].geometric_bounds().unwrap(), own, 0.01)); + let NodeKind::Text(t) = &children[1].kind else { panic!("text") }; + assert!(t.runs[0].style.underline && t.runs[0].style.strikethrough); + // Under clip paths nested as deep as the stack, the text's own clip is still the innermost. + for i in 0..32 { + records.insert(0, (67, le(&[1]))); + records.insert(0, (60, vec![])); + records.insert(0, (43, le(&[100 + i, 100 + i, 4000 - i, 2500 - i]))); + records.insert(0, (59, vec![])); + } + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let mut n = back.document.layers[0].children().unwrap()[0].clone(); + while let NodeKind::Group { children, clip: true } = &n.kind + && matches!(children.last().map(|c| &c.kind), Some(NodeKind::Group { .. })) + { + n = children.last().unwrap().clone(); + } + let NodeKind::Group { children, clip: true } = &n.kind else { panic!("a clipping group: {:?}", n.kind) }; + assert!(near(children[0].geometric_bounds().unwrap(), own, 0.01), "{:?}", children[0].geometric_bounds()); + assert!(matches!(children[1].kind, NodeKind::Text(_))); +} + +#[test] +fn excluded_clip_rectangles_clip() { + // EXCLUDECLIPRECT cuts the top-left of the picture out of the clip; the rectangle crosses it. + // The clip it cuts from is the picture's bounds (5000 × 3000 device pixels). + let records = vec![(29, le(&[0, 0, 1500, 1500])), (43, le(&[1000, 1000, 2000, 1500]))]; + let mut bytes = hand_emf(5000, 3000, &records); + for (i, v) in [0i32, 0, 5000, 3000].iter().enumerate() { + bytes[8 + i * 4..12 + i * 4].copy_from_slice(&v.to_le_bytes()); + } + let back = import(&bytes).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + assert!(matches!(layer[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", layer[0].kind); +} + +#[test] +fn shapes_under_the_same_clip_share_one_clipping_group() { + let records = vec![(30, le(&[0, 0, 1500, 1500])), (43, le(&[100, 100, 200, 200])), (43, le(&[300, 300, 400, 400]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + assert_eq!(children.len(), 3, "the clip and both rectangles"); +} + +#[test] +fn a_record_whose_size_is_not_a_multiple_of_4_ends_the_file() { + let records = vec![(43, le(&[100, 100, 200, 200])), (43, le(&[300, 300, 400, 400])), (43, le(&[500, 500, 600, 600]))]; + let mut bytes = hand_emf(5000, 3000, &records); + // The second rectangle's size, after the header and the first rectangle. + let header = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize; + let first = u32::from_le_bytes(bytes[header + 4..header + 8].try_into().unwrap()) as usize; + let at = header + first + 4; + let size = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()); + bytes[at..at + 4].copy_from_slice(&(size + 2).to_le_bytes()); + let back = import(&bytes).unwrap(); + assert_eq!(paths(&back.document).len(), 1); + assert_eq!(back.warnings, vec!["the file ends early: what it held was imported".to_string()]); +} + +#[test] +fn wmf_with_an_inverted_placeable_box_is_read_at_its_units_an_inch() { + // The box runs from right to left, so it gives no size: the window (1440 units square, one inch + // at the header's 1440 units an inch) does. The rectangle is its top-left quarter. + let records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), (0x041B, le16(&[720, 720, 0, 0]))]; + let mut bytes = hand_wmf(1440, 1440, &records); + bytes[6..8].copy_from_slice(&1440i16.to_le_bytes()); + bytes[10..12].copy_from_slice(&0i16.to_le_bytes()); + let back = import(&bytes).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 72.0, 72.0), 1e-6), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 36.0, 36.0), 1e-6), "{:?}", p[0].geometric_bounds()); +} + +#[test] +fn emf_without_a_frame_is_read_at_its_device_resolution() { + // An empty frame, and bounds of 5000 × 3000 device pixels at 100 pixels a millimetre. + let mut bytes = hand_emf(0, 0, &[(43, le(&[1000, 1000, 2000, 1500]))]); + for (i, v) in [0i32, 0, 5000, 3000].iter().enumerate() { + bytes[8 + i * 4..12 + i * 4].copy_from_slice(&v.to_le_bytes()); + } + let back = import(&bytes).unwrap(); + let mm = 72.0 / 25.4; + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0 * mm, 30.0 * mm), 0.01), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(10.0 * mm, 10.0 * mm, 20.0 * mm, 15.0 * mm), 0.01), "{:?}", p[0].geometric_bounds()); + // A device 50 mm high: 200 pixels a millimetre down, so everything is half as tall. + bytes[84..88].copy_from_slice(&50i32.to_le_bytes()); + let back = import(&bytes).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0 * mm, 15.0 * mm), 0.01), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(10.0 * mm, 5.0 * mm, 20.0 * mm, 7.5 * mm), 0.01), "{:?}", p[0].geometric_bounds()); } diff --git a/docs/mcp.md b/docs/mcp.md index eb60457600..17028d3952 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7700,10 +7700,11 @@ and is missing from the picture" once for all such images. `document.open` and `file.place` read `.emf` and `.wmf` (placeable or not) into one artboard, the picture's frame, and one layer: paths with their fills and strokes (pens become strokes with caps, joins and dashes), clipping groups, -images and point type in the font the file names. Records VectorCraft doesn't read (EMF+ drawing among them) are -skipped with one warning. `clipboard.importEmf {dataBase64, center?}` loads an EMF or WMF picture into the clipboard -for `edit.paste`; the app pastes `image/emf` from the system clipboard ahead of text and bitmaps when the platform's -clipboard service offers it (the Windows desktop service doesn't read metafiles yet). +images and point type in the font the file names. An EMF that draws with EMF+ records is read from them. Hatched and +pattern brushes fill with their average color. Records VectorCraft doesn't read are skipped with one warning. +`clipboard.importEmf {dataBase64, center?}` loads an EMF or WMF picture into the clipboard for `edit.paste`; the app +pastes `image/emf` from the system clipboard ahead of text and bitmaps when the platform's clipboard service offers it +(the Windows desktop service doesn't read metafiles yet). ```json {"name":"export","arguments":{"path":"/tmp/logo.emf"}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad7e9f4eab..7359d135d8 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 - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - PICT pictures (`.pct`, `.pic`, and `.pict` as VectorCraft's own name) open and place, as [the help page](https://helpx.adobe.com/archive/illustrator/illustrator-cs5-5-troubleshooting.pdf) lists Macintosh PICT under both opening and placing: versions 1 and 2, with or without the file's 512 bytes of application data, read by the opcodes of Apple's [Opcodes in Pictures](https://dev.os9.ca/techpubs/mac/QuickDraw/QuickDraw-461.html). Lines, rectangles, rounded rectangles, ovals, arcs, polygons and regions become paths in their pen and pattern colors, text becomes point type in the font the picture names, bitmaps, pixel maps and QuickTime JPEG images become images, and the clip region clips what follows it; File › Place places the picture as one group clipped to its frame. Inverted shapes, other transfer modes and other QuickTime formats come back as warnings, and a picture cut short keeps what came before it (`crates/metafile/src/tests/pict.rs`, `crates/engine/src/cmd/fileio/tests_pict.rs`). + - EMF and WMF files are read by the shared `craft-metafile` reader and open, place and paste as editable paths, clipping groups, images and point type: an EMF drawn with EMF+ records (on their own, or beside plain records) is read from them instead of opening empty, and hatched and pattern brushes fill with their average color (`crates/metafile/src/tests/import.rs`). - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). From c410e3fbb55bde577ab4336309c4b5396f6dd984 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:25:25 -0800 Subject: [PATCH 29/82] Brushes: the renderer's brush cull room counts only painted brushed strokes The doc comment on pad() now says a brushed stroke needs a paint to get room, as vectorcraft_brush::reach does. --- crates/render/src/brush_fx.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index fe7eb0f804..5844a2ef7f 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -155,7 +155,7 @@ pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnO /// How far brush art can paint past `n`'s brushed strokes, or past the brushed strokes in the art /// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it -/// has a visible brushed stroke and of the objects in that art that have one +/// has a visible brushed stroke with a paint and of the objects in that art that have one /// ([`SymbolBoxes::brush_width`]) or the pieces live-effect plug-ins paint for them /// ([`crate::fx::painted_brush_width`]), and 0 when neither has one. The room for `n`'s own strokes /// is [`vectorcraft_brush::reach`], which the exports leave too. From f8e70c141599449d6cbab41ea41bbb3bffc85e02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:35:25 -0800 Subject: [PATCH 30/82] Metafile import: translucent strokes stay apart, and each pattern tile is averaged once Strokes drawn one after another with the same pen join into one path only when the pen is opaque. A translucent stroke keeps its own opacity, so where two of them overlap both show. Items filled with the same pattern brush share its tile, and the tile's average color is now worked out once per tile instead of once per item. --- crates/metafile/src/import/picture.rs | 18 ++++++++++++++---- crates/metafile/src/tests/import.rs | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index d269fd27ca..8373253ae4 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -2,8 +2,8 @@ //! picture, and the picture's items become document objects here. //! //! - A filled or stroked path becomes a path with a fill and a stroke (caps, joins, miter limit -//! and dashes from the pen). Strokes drawn one after another, each starting where the last one -//! ended, with the same pen and clip, join into one path. +//! and dashes from the pen). Opaque strokes drawn one after another, each starting where the +//! last one ended, with the same pen and clip, join into one path. //! - A path filled with a pattern or hatched brush is filled with the average color of the //! brush's tile. //! - A bitmap becomes an image (its pixels as PNG) placed where the picture draws it, turned or @@ -18,7 +18,7 @@ //! at its device's pixels a millimetre (96 pixels an inch when the header doesn't say). use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use craft_metafile::{Cap, ClipPath, HAlign, Item, Join, LineStyle, Seg, VAlign}; use vectorcraft_color::Paint; @@ -106,6 +106,10 @@ struct Pending { type MadeClips = HashMap<*const ClipPath, (Arc, Option>)>; /// The last clip region and the clip made for it. type MadeRegion = (Vec<[f32; 4]>, Option>); +/// Brush tiles already averaged, by address: items filled with the same brush share its tile. The +/// `Weak` keeps the address from being reused, and the tile's pixels are freed once the last item +/// that uses it has been read. +type Averaged = HashMap<*const craft_metafile::Tile, (Weak, Option<[u8; 4]>)>; struct Mapper { /// Picture units → points. @@ -120,6 +124,7 @@ struct Mapper { paths: MadeClips, region: Option, pending: Option, + averaged: Averaged, } fn color(c: [u8; 4]) -> Paint { @@ -161,6 +166,7 @@ impl Mapper { paths: HashMap::new(), region: None, pending: None, + averaged: HashMap::new(), } } @@ -317,8 +323,10 @@ impl Mapper { PathEl::MoveTo(p) => Some(*p), _ => None, }); + // A translucent stroke stays apart, so where strokes overlap each one shows. if let Some(p) = &mut self.pending && p.pen == pen + && pen.color[3] == u8::MAX && start.is_some() && p.path.elements().last().and_then(|e| e.end_point()) == start { @@ -338,7 +346,9 @@ impl Mapper { Item::Pattern { segs, even_odd, tile, region, clips } => { self.flush(); let Some(chain) = self.chain(region, &clips) else { return }; - let Some(c) = average(&tile.rgba) else { return }; + let Some(c) = self.averaged.entry(Arc::as_ptr(&tile)).or_insert_with(|| (Arc::downgrade(&tile), average(&tile.rgba))).1 else { + return; + }; self.warn(PATTERNS); let bp = self.path(&segs); if let Some(n) = self.path_node(bp, Some(c), None, even_odd) { diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 689037d277..69abf02e86 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -548,6 +548,29 @@ fn dual_emf_plus_files_draw_their_emf_plus_records() { assert!((f.opacity - 128.0 / 255.0).abs() < 1e-6, "{}", f.opacity); } +#[test] +fn translucent_strokes_drawn_one_after_another_stay_apart() { + // EmfPlusObject: pen 0, 1 pixel wide, with a solid brush of `argb`; then two EmfPlusDrawLines + // with it, from A to B and from B back to A. + let header = le(&[0xDBC0_1002u32 as i32, 0, 96, 96]); + let lines = |argb: u32| { + let pen = [le(&[0xDBC0_1002u32 as i32, 0, 0, 2]), le_f32(&[1.0]), le(&[0xDBC0_1002u32 as i32, 0, argb as i32])].concat(); + let a_to_b = [le(&[2]), le_f32(&[1000.0, 1000.0, 3000.0, 1000.0])].concat(); + let b_to_a = [le(&[2]), le_f32(&[3000.0, 1000.0, 1000.0, 1000.0])].concat(); + let comment = emf_plus(&[(0x4001, 0, header.clone()), (0x4008, 0x0200, pen), (0x400D, 0, a_to_b), (0x400D, 0, b_to_a), (0x4002, 0, vec![])]); + paths(&import(&hand_emf(5000, 3000, &[(70, comment)])).unwrap().document) + }; + // Each translucent stroke keeps its own opacity, so where they overlap both show. + let p = lines(0x80FF_0000); + assert_eq!(p.len(), 2, "{p:?}"); + for n in &p { + let AppearanceItem::Stroke(st) = &n.appearance.items[0] else { panic!("a stroke") }; + assert!((st.opacity - 128.0 / 255.0).abs() < 1e-6, "{}", st.opacity); + } + // Opaque strokes still join into one path. + assert_eq!(lines(0xFFFF_0000).len(), 1); +} + #[test] fn pattern_brushes_fill_with_their_average_color() { // CREATEDIBPATTERNBRUSHPT: a 2 × 2 tile of 24-bit pixels, two red and two blue (bottom-up rows From d5e58b0e4bb6962218f3933ed168b95f5f605efa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:46:17 -0800 Subject: [PATCH 31/82] Metafile import: a clip of several figures along a box's sides still clips A clip path is left out only when it is one rectangle around the whole picture. Two open figures along the top and bottom of a box enclose nothing, and the objects under such a clip are no longer imported unclipped. --- crates/metafile/src/import.rs | 9 ++++----- crates/metafile/src/tests/import.rs | 7 +++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index 43023e0183..dce77b2d7a 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -44,8 +44,9 @@ fn sane(r: Rect) -> bool { [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) } -/// Is `path` an upright rectangle around the whole of `frame`? Writers clip to one first; it clips -/// nothing in the picture. +/// Is `path` one upright rectangle around the whole of `frame`? Writers clip to one first; it clips +/// nothing in the picture. A path of more than one figure is not one rectangle, even when its +/// figures run along the sides of a box. fn around(path: &BezPath, frame: Rect) -> bool { if path.elements().len() > 6 { return false; @@ -67,9 +68,7 @@ fn around(path: &BezPath, frame: Rect) -> bool { }; let Some(i) = corners.iter().position(|c| near(*c, p)) else { return false }; if start { - if let (Some(f), Some(l)) = (first, last) - && !along(f, l) - { + if first.is_some() { return false; } first = Some(i); diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 69abf02e86..4dcb3020c4 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -524,8 +524,11 @@ fn a_clip_whose_box_holds_the_picture_still_clips() { // A bow tie through the four corners of a box around the picture. let mut bow_tie = le(&[0, 0, 0, 0, 4]); bow_tie.extend(le(&[-500, -500, 5500, 3500, 5500, -500, -500, 3500])); - for clip in [diamond, bow_tie] { - let records = vec![(59, vec![]), (3, clip), (60, vec![]), (67, le(&[5])), (43, le(&[0, 0, 5000, 3000]))]; + // Two open figures through the four corners, the top edge and the bottom edge, which enclose + // nothing: MOVETOEX, LINETO, MOVETOEX, LINETO. + let edges = vec![(27, le(&[-500, -500])), (54, le(&[5500, -500])), (27, le(&[5500, 3500])), (54, le(&[-500, 3500]))]; + for clip in [vec![(3, diamond)], vec![(3, bow_tie)], edges] { + let records = [vec![(59, vec![])], clip, vec![(60, vec![]), (67, le(&[5])), (43, le(&[0, 0, 5000, 3000]))]].concat(); let back = import(&hand_emf(5000, 3000, &records)).unwrap(); let layer = back.document.layers[0].children().unwrap(); assert_eq!(layer.len(), 1); From 43823fc8442d7cd5b5805ad57e356cc96ca9a391 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:51:15 -0800 Subject: [PATCH 32/82] Blend: a stroke gradient started on a step keeps the link after it When a closed key path now starts where its stroke gradient steps between two stops of one color linked to different swatches, the gradient's new first stop takes the swatch link of the stop after the step instead of the one before it. --- crates/doc/src/blend.rs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 0eabd7eba8..69016363af 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -677,14 +677,21 @@ fn gradient_from(g: &Gradient, s: f32) -> Gradient { // The first of `own` not yet matched: the parts' stops run in order, so the links are // found in one pass. let mut j = 0; - for st in g.span(from, to).stops { + for (k, st) in g.span(from, to).stops.into_iter().enumerate() { let p = from + (to - from) * st.offset; while own.get(j).is_some_and(|o| o.offset <= p - STEP) { j += 1; } - let hit = (j..own.len()) - .take_while(|&i| own.get(i).is_some_and(|o| o.offset < p + STEP)) - .find(|&i| own.get(i).is_some_and(|o| o.color == st.color && o.opacity == st.opacity)); + let same = |i: usize| own.get(i).is_some_and(|o| o.color == st.color && o.opacity == st.opacity); + let near = (j..own.len()).take_while(|&i| own.get(i).is_some_and(|o| o.offset < p + STEP)); + let mut hit = near.clone().find(|&i| same(i)); + // A part starting on a step starts with the color after it, so its first stop takes + // the link of the step's last stop of that color. + if k == 0 + && let Some(at) = hit.and_then(|i| own.get(i)).map(|o| o.offset) + { + hit = near.filter(|&i| own.get(i).is_some_and(|o| o.offset == at) && same(i)).last().or(hit); + } let (swatch, tint, plates) = match hit.and_then(|i| own.get(i).map(|o| (i, o))) { Some((i, o)) => { j = i + 1; @@ -2987,6 +2994,23 @@ mod tests { p => panic!("a gradient: {p:?}"), }; assert_eq!(links, vec![Some("Ink".into()), Some("Paper".into())], "{:?}", s.paint); + // A step of one color between two swatches, where the turned subpath now starts: the + // start takes the link after the step and the end the link before it. + let seam_step = keyed(square(100.0), &|s| { + let mut g = Gradient::default(); + let gray = Color::rgb8(128, 128, 128); + let (mut before, mut after) = (GradientStop::new(0.5, gray), GradientStop::new(0.5, gray)); + (before.swatch, after.swatch) = (Some("Ink".into()), Some("Paper".into())); + g.stops.splice(1..1, [before, after]); + s.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + s.gradient_mode = StrokeGradientMode::Along; + }); + let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &seam_step, (Some(0), Some(2))).b); + let ends = match &s.paint { + Paint::Gradient(g) => (g.gradient.stops.first().map(|st| st.swatch.clone()), g.gradient.stops.last().map(|st| st.swatch.clone())), + p => panic!("a gradient: {p:?}"), + }; + assert_eq!(ends, (Some(Some("Paper".into())), Some(Some("Ink".into()))), "{:?}", s.paint); // Blended with a gradient of other stops, the steps keep that step. let three = keyed(square(0.0), &|s| { let mut g = Gradient::default(); From 9f23b0a5a477888ccb1e44b67cf63f958bc86029 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:47:35 -0800 Subject: [PATCH 33/82] OpenType panel: the tracked-ligatures note reads every selected story frame With the Selection tool, the note that ligatures are off while the text is tracked read only the first run of the first selected frame. It now shows when any character the panel reads has Standard Ligatures on, tracking outside the shaper's limits and no explicit liga or clig tag, including the runs of later frames of a selected threaded story, locked or hidden. The panel's other controls already read those runs through character::shared; a new any_style helper walks the same styles. The panel's commands still change only the selected frames. --- crates/ui-egui/src/panels/character.rs | 35 ++++++++++++---- crates/ui-egui/src/panels/opentype.rs | 55 +++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index d003fbcbce..e3c1f68bef 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -143,33 +143,52 @@ fn shared_styles( app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject, &vectorcraft_doc::ParaStyle, &CharStyle, bool) -> Option, ) -> Option { - let st = app.session.active()?; + let mut acc = Shared::None; + each_style(app, |t, para, style, inline| acc.add(f(t, para, style, inline))); + acc.value().flatten() +} + +/// Whether `f` holds for any of the character styles [`shared`] reads. +pub(crate) fn any_style(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> bool) -> bool { + let mut any = false; + each_style(app, |_, _, style, _| any = any || f(style)); + any +} + +/// Call `f` with each character style the panels act on, as [`shared_styles`] describes: the +/// caret's style at a Type tool caret, else each piece of the runs the Type tool's selection +/// covers, else of every run of every frame of the selected stories. +fn each_style(app: &VectorcraftApp, mut f: impl FnMut(&vectorcraft_doc::TextObject, &vectorcraft_doc::ParaStyle, &CharStyle, bool)) { + let Some(st) = app.session.active() else { + return; + }; let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) { Some(NodeKind::Text(t)) => Some(t), _ => None, }; - let mut acc = Shared::None; if let Some((id, a, b)) = text_editing(app) { - let t = text(id)?; + let Some(t) = text(id) else { + return; + }; if b <= a { let para = t.plain_text().get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count()); - return f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b), false); + f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b), false); + return; } match text_story(app) { // After Select All in threaded text: all the text of every frame of the story. Some(story) => { for t in story.into_iter().filter_map(text) { - each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline))); + each_paragraph_run(t, None, |para, style, inline| f(t, para, style, inline)); } } - None => each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline))), + None => each_paragraph_run(t, Some((a, b)), |para, style, inline| f(t, para, style, inline)), } } else { for t in selected_story_texts(app) { - each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline))); + each_paragraph_run(t, None, |para, style, inline| f(t, para, style, inline)); } } - acc.value().flatten() } /// `f` of the paragraph and style of each piece of `t`'s runs that lies in one paragraph (a diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 64daf1ac76..593d6e6a55 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -7,7 +7,7 @@ use serde_json::json; use vectorcraft_doc::{CharStyle, NodeKind}; use vectorcraft_text::{EXTRA_FEATURES, FontDb, OtFeatures, StylisticSet}; -use super::character::{shared, text_editing, text_style}; +use super::character::{any_style, shared, text_editing, text_style}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; @@ -226,7 +226,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); }); }); - if cur.ligatures && vectorcraft_text::ligatures_suppressed_by(st.tracking) && !vectorcraft_text::explicit_ligatures(&st.features) { + if tracking_drops_ligatures(app) { widgets::dim_label(ui, tl!("Ligatures are off while the text is tracked.")); } if let Some(c) = change { @@ -234,6 +234,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Whether tracking turns off the standard ligatures of any character the panel acts on: one with +/// Standard Ligatures on, tracked past [`vectorcraft_text::ligatures_suppressed_by`]'s limit and +/// without an explicit `liga` or `clig` tag ([`vectorcraft_text::explicit_ligatures`]). +fn tracking_drops_ligatures(app: &VectorcraftApp) -> bool { + any_style(app, |s| { + OtFeatures::from_tags(s.features.iter().map(String::as_str)).ligatures + && vectorcraft_text::ligatures_suppressed_by(s.tracking) + && !vectorcraft_text::explicit_ligatures(&s.features) + }) +} + /// The stylistic sets of the font `st` names. A font that is not available offers none, rather than /// the sets of the face drawn in its place. fn offered_sets(st: &vectorcraft_doc::CharStyle) -> Vec { @@ -278,6 +289,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { use serde_json::Value; + use vectorcraft_doc::NodeId; use super::*; @@ -535,6 +547,45 @@ mod tests { ); } + /// With the Selection tool on the first frame of a threaded story, the tracked-ligatures note + /// reads every frame: it shows when a later, locked frame is tracked with Standard Ligatures + /// on, and not once that frame's ligatures are explicit, off, or its tracking is back within + /// the limit. + #[test] + fn ligature_note_reads_every_frame_of_the_story() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(6); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.session.execute("text.create", &area).unwrap()["id"].clone(); + let b = app.session.execute("text.thread.add", &json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].clone(); + assert!(!run_features(&mut app, &b, 4).is_empty(), "the second frame holds text"); + app.session.execute("text.setStyle", &json!({"ids": [b], "tracking": 100})).unwrap(); + app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); + app.session.execute("object.lock", &json!({})).unwrap(); + app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + assert_eq!(text_editing(&app), None); + assert_eq!(text_style(&app).unwrap().0.tracking, 0.0, "the panel reads its first run from the first frame"); + assert!(tracking_drops_ligatures(&app), "the tracked second frame shows the note"); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + // Commands write `liga` as the default and leave it out; an opened file can keep it. + let b_id = NodeId(b.as_u64().unwrap()); + for tags in [&["liga"][..], &["clig"][..], &["-liga"][..]] { + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(b_id).map(|n| &mut n.kind) else { panic!("no second frame") }; + for r in &mut t.runs { + r.style.features = tags.iter().map(|s| s.to_string()).collect(); + } + assert!(!tracking_drops_ligatures(&app), "{tags:?}"); + } + app.session.execute("text.setStyle", &json!({"ids": [b], "features": [], "tracking": 20})).unwrap(); + assert!(!tracking_drops_ligatures(&app), "tracking within the limit"); + app.session.execute("text.setStyle", &json!({"ids": [b], "tracking": -100})).unwrap(); + assert!(tracking_drops_ligatures(&app), "tight tracking shows the note too"); + } + /// At a caret whose insertion style is already a denominator, Denominator still reaches the /// characters the command styles at a caret (all of them) in one undo step; chosen again once /// every character has it, it adds no undo step. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..12c9591848 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1351,6 +1351,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. 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 OpenType panel's note that ligatures are off while the text is tracked shows when any character the panel reads has Standard Ligatures on, tracking outside -20 to 55 thousandths of an em, and no explicit `liga` or `clig` tag. With the Selection tool, those characters include every frame of a selected threaded story, locked and hidden frames included; the panel's commands still change only the selected frames. The tracking limits and this rule are VectorCraft's own. Tests: `panels::opentype::tests::ligature_note_reads_every_frame_of_the_story`. 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`. 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). From 9e85368a444309e2385f639b6c413bbbd62d673c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:55:40 -0800 Subject: [PATCH 34/82] Metafile import: a file that reaches the most objects one file may hold says so The reader keeps at most 200,000 objects from one file and stops reading there. A picture that reaches that count now carries a warning that any objects after them were not read, and objects dropped past the import's own object limit carry a warning too. --- crates/metafile/src/import/picture.rs | 12 +++++++++++- crates/metafile/src/tests/import.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index 8373253ae4..4689572a62 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -36,6 +36,13 @@ use crate::{Imported, Kind}; const PATTERNS: &str = "hatched and pattern brushes are filled with their average color"; const ENDS_EARLY: &str = "the file ends early: what it held was imported"; +const TOO_MANY: &str = "objects past the most one file may hold were left out"; +const AT_LIMIT: &str = "the file reached the most objects one file may hold: any after them were not read"; + +/// The most items `craft_metafile` keeps from one file (its `MAX_ITEMS`). It stops reading at the +/// limit and does not say whether more items followed, so a picture holding this many may have +/// been cut short. +const READER_ITEMS: usize = 200_000; /// Points a unit of a WMF without a placeable header (1440 units an inch). const WMF_UNIT: f64 = 72.0 / 1440.0; @@ -243,7 +250,7 @@ impl Mapper { fn push(&mut self, clips: Vec>, mut node: Node) { if self.out.len() >= MAX_NODES { - return; + return self.warn(TOO_MANY); } if !node.visual_bounds().is_none_or(sane) { return self.warn(FAR_AWAY); @@ -526,6 +533,9 @@ pub(crate) fn import(bytes: &[u8]) -> Result { if ends_early(bytes, kind) { m.warn(ENDS_EARLY); } + if pic.items.len() >= READER_ITEMS { + m.warn(AT_LIMIT); + } for item in pic.items { m.item(item); } diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 4dcb3020c4..a1ddcc335e 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -370,6 +370,18 @@ fn damaged_files_fail_or_import_partly() { assert!(import(&huge).is_ok()); } +#[test] +fn a_file_with_more_objects_than_are_read_says_so() { + // RECTANGLE records: 10 more than the reader keeps, then a few under that. + let rect = (43, le(&[0, 0, 100, 100])); + let many = hand_emf(5000, 3000, &vec![rect.clone(); 200_010]); + let back = import(&many).unwrap(); + assert_eq!(paths(&back.document).len(), 200_000); + assert!(back.warnings.iter().any(|w| w.contains("reached the most objects")), "{:?}", back.warnings); + let few = import(&hand_emf(5000, 3000, &vec![rect; 3])).unwrap(); + assert!(few.warnings.is_empty(), "{:?}", few.warnings); +} + /// An EMR_STRETCHDIBITS copying `png` (a BI_PNG bitmap) to a 10 × 10 box. fn png_stretch_dibits(png: &[u8]) -> Vec { // Bounds; destination, source and its size; the BITMAPINFO and the bits (from the record start); From bbe94806564c5156369f5fe2ccc76b88b3a76067 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:00:35 -0800 Subject: [PATCH 35/82] Shape Builder: an open path counts as filled by the fill rows that draw Consider Open Filled Paths as Closed decided whether an open path was filled from its top fill row only. A path whose top fill is None over a painted lower fill stayed open, and one whose only painted fill was hidden or at 0% opacity closed. The region map, merge inputs and the Clicking Stroke Splits the Path cut now use Appearance::has_drawn_fill, so any shown, painted fill row above 0% opacity counts, as the canvas paints it. The Shape Builder's Live Paint face and edge filters had the same top-row check; they now use has_drawn_fill and has_drawn_stroke, so a face or edge is taken when one of its rows draws. --- crates/engine/src/cmd/buildcmds.rs | 2 +- .../engine/src/cmd/buildcmds/shape_builder.rs | 2 +- crates/engine/src/tests/build.rs | 130 ++++++++++++++++++ crates/tools/src/builder/lookup.rs | 16 ++- crates/tools/src/builder/map.rs | 5 +- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 7 files changed, 147 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 06ec06e348..5e011331b7 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Shape Builder", [], None, - "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} replaces the paths it uses where they are; other selected objects (type, hidden or locked objects, clipping paths) stay, and a group goes only when it is left empty → {ids (selected after: each selected object still there, or what took its place), merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", + "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it, where filled means any fill row is shown, painted and above 0% opacity, whichever row is on top; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} replaces the paths it uses where they are; other selected objects (type, hidden or locked objects, clipping paths) stay, and a group goes only when it is left empty → {ids (selected after: each selected object still there, or what took its place), merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", has_selection_or_ids, sb_merge ), diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index fea79a0e6a..472ba2208b 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -63,7 +63,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { && let Some(&q) = pts.first() && let Some((i, run)) = map.stroke_at(&outlines, q, tol) && let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i)) - && let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn)) + && let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && leaf.appearance.has_drawn_fill(), b::outline_snap(drawn)) && rest.length() > b::outline_snap(drawn) { // The path keeps the rest of it, and a copy of it with the run under the click goes diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index e0d68ee949..ef833a9bfe 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -1490,6 +1490,93 @@ fn shape_builder_open_filled_paths_cut_without_open_filled_as_closed() { assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 5000.0).abs() < 1e-6); } +/// The fill rows of an open path's appearance (bottom first) for the open-path tests below, with +/// a 1 pt black stroke on top: a painted fill under an unpainted top fill, a painted fill that is +/// hidden, one with an opacity of 0, and a painted fill under a hidden painted top fill. +fn open_path_fill_stacks() -> [(&'static str, Vec, bool); 4] { + use vectorcraft_doc::{FillLayer, StrokeLayer}; + let fill = |p: Paint, visible: bool, opacity: f32| { + let mut f = FillLayer::new(p); + f.visible = visible; + f.opacity = opacity; + AppearanceItem::Fill(f) + }; + 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 stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); + [ + ("a painted fill under an unpainted top fill", vec![fill(red(), true, 1.0), fill(Paint::None, true, 1.0), stroke()], true), + ("a hidden painted fill", vec![fill(red(), false, 1.0), stroke()], false), + ("a painted fill with an opacity of 0", vec![fill(red(), true, 0.0), stroke()], false), + ("a painted fill under a hidden painted top fill", vec![fill(red(), true, 1.0), fill(blue(), false, 1.0), stroke()], true), + ] +} + +/// Consider Open Filled Paths as Closed: an open path counts as filled when any of its fill rows +/// draws (shown, painted and above 0% opacity), whichever row is on top. The map, a merge inside +/// the area and what a merge keeps of the path follow the rows that draw; with the option off, none +/// of the paths closes. +#[test] +fn shape_builder_closes_open_paths_by_the_fill_rows_that_draw() { + for (what, items, closes) in open_path_fill_stacks() { + let mut s = session(); + let r = s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}]})).unwrap(); + let p = NodeId(r["id"].as_u64().unwrap()); + s.edit("t", |d, _| { + d.node_mut(p).unwrap().appearance.items = items; + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [p.0]})).unwrap(); + let r = s.execute("shapeBuilder.regions", &json!({})).unwrap(); + assert_eq!( + (r["regions"].as_array().unwrap().len(), r["lines"].as_array().unwrap().len()), + if closes { (1, 0) } else { (0, 1) }, + "{what}: {r}" + ); + let r = s.execute("shapeBuilder.regions", &json!({"openFilledAsClosed": false})).unwrap(); + assert_eq!((r["regions"].as_array().unwrap().len(), r["lines"].as_array().unwrap().len()), (0, 1), "{what}, option off: {r}"); + let n = node(&s, p); + for on in [true, false] { + let inputs = vectorcraft_tools::builder::merge_inputs(&[&n], on); + assert_eq!(inputs.iter().map(|i| i.fill_closes).collect::>(), vec![closes && on], "{what}, option {on}"); + } + let merged = s.execute("shapeBuilder.merge", &json!({"points": [[75, 25]]})); + if closes { + assert!((area(&s, NodeId(merged.unwrap()["merged"].as_u64().unwrap())) - 5000.0).abs() < 1e-6, "{what}"); + } else { + assert!(merged.is_err(), "{what}: the open path encloses no region"); + } + } +} + +/// Clicking Stroke Splits the Path on the middle side of an open U cuts it along the outline its +/// drawn fill closes: with a fill row that draws, what is left runs on from the cut round the +/// implied closing edge (the top side, then the bottom side); without one, what is left is the +/// open path's two ends in path order (the bottom side, then the top side). +#[test] +fn shape_builder_stroke_split_closes_open_paths_by_the_fill_rows_that_draw() { + for (what, items, closes) in open_path_fill_stacks() { + let mut s = session(); + let r = + s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}]})).unwrap(); + let p = NodeId(r["id"].as_u64().unwrap()); + s.edit("t", |d, _| { + d.node_mut(p).unwrap().appearance.items = items; + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [p.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 50]], "splitStroke": true})).unwrap(); + let out = ids(&r); + assert_eq!(shapes_of(&s, &out), vec![(false, 4, 200.0), (false, 2, 100.0)], "{what}"); + let (rest, _) = outline(&node(&s, out[0])); + let starts: Vec<(f64, f64)> = rest.subpaths.iter().map(|sp| (sp.anchors[0].p.x.round(), sp.anchors[0].p.y.round())).collect(); + let order = if closes { vec![(100.0, 100.0), (0.0, 0.0)] } else { vec![(0.0, 0.0), (100.0, 100.0)] }; + assert_eq!(starts, order, "{what}"); + } +} + /// Pick Color From Color Swatches: the merged shape takes the current fill, not the fill of the /// object under the first point; setting the current fill without `ids` leaves the selection's. #[test] @@ -4004,6 +4091,49 @@ fn shape_builder_uses_the_filled_faces_of_a_live_paint_group() { assert!((stroked_length(&s, &out) - 600.0).abs() < 1.0, "{}", stroked_length(&s, &out)); } +/// The Shape Builder takes a Live Paint face or edge when any of its fill or stroke rows draws, +/// whichever row is on top: a painted lower row under an unpainted top row counts, and a part +/// whose only painted row is hidden, transparent or (for an edge) 0 pt wide does not. +#[test] +fn shape_builder_takes_the_live_paint_faces_and_edges_that_draw() { + use vectorcraft_doc::{FillLayer, StrokeLayer}; + let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p)); + let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w)); + let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let hidden = |mut i: AppearanceItem| { + match &mut i { + AppearanceItem::Fill(f) => f.visible = false, + AppearanceItem::Stroke(st) => st.visible = false, + } + i + }; + let clear = |mut i: AppearanceItem| { + match &mut i { + AppearanceItem::Fill(f) => f.opacity = 0.0, + AppearanceItem::Stroke(st) => st.opacity = 0.0, + } + i + }; + let mut s = session(); + let g = live(&mut s); + let lower_fill = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), fill(Paint::None)], 1.0); + let hidden_fill = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red()))], 1.0); + let clear_fill = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![clear(fill(red()))], 1.0); + let lower_stroke = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 2.0), stroke(Paint::None, 1.0)], 1.0); + let hidden_stroke = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![hidden(stroke(red(), 2.0))], 1.0); + let thin_stroke = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0); + let clear_stroke = set_live_part(&mut s, g, None, Some((50.0, 50.0)), vec![clear(stroke(red(), 2.0))], 1.0); + let parts = [lower_fill, hidden_fill, clear_fill, lower_stroke, hidden_stroke, thin_stroke, clear_stroke]; + assert_eq!(parts.iter().collect::>().len(), parts.len()); + let d = s.doc().unwrap(); + let (_, leaves) = vectorcraft_tools::builder::shape_builder_shapes(&d.doc, &[g], true); + let taken: Vec = leaves.iter().map(|l| l.id).collect(); + assert!(taken.contains(&lower_fill) && taken.contains(&lower_stroke), "a painted lower row draws: {taken:?}"); + for id in [hidden_fill, clear_fill, hidden_stroke, thin_stroke, clear_stroke] { + assert!(!taken.contains(&id), "{id:?} draws nothing: {taken:?}"); + } +} + /// An edge that closes on itself is a line around its face, not a second shape over it. #[test] fn shape_builder_takes_a_closed_live_paint_edge_as_a_line() { diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 5e85d9339d..ba55f45e25 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -162,8 +162,8 @@ fn collect_members<'a>(n: &'a Node, builder: bool, out: &mut Vec<&'a Node>) { NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), NodeKind::Group { .. } if builder && is_live_paint(n) => { - out.extend(faces(n).into_iter().filter(|f| !f.visible || f.locked || !f.appearance.fill_paint().is_none()).map(|f| &**f)); - out.extend(edges(n).into_iter().filter(|e| !e.visible || e.locked || !e.appearance.stroke_paint().is_none()).map(|e| &**e)); + out.extend(faces(n).into_iter().filter(|f| !f.visible || f.locked || f.appearance.has_drawn_fill()).map(|f| &**f)); + out.extend(edges(n).into_iter().filter(|e| !e.visible || e.locked || e.appearance.has_drawn_stroke()).map(|e| &**e)); } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { for c in children { @@ -244,10 +244,12 @@ fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], builder: bool) -> (Vec(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { let (mut shapes, leaves) = shapes_of(doc, roots, true); for (s, l) in shapes.iter_mut().zip(&leaves) { @@ -257,7 +259,7 @@ pub fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled s.path.subpaths = s.path.subpaths.iter().flat_map(|sp| sp.cut_at(&start)).collect(); continue; } - if !open_filled_as_closed || l.appearance.fill_paint().is_none() { + if !open_filled_as_closed || !l.appearance.has_drawn_fill() { continue; } for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) { diff --git a/crates/tools/src/builder/map.rs b/crates/tools/src/builder/map.rs index 74ec31f69a..6b524e9af9 100644 --- a/crates/tools/src/builder/map.rs +++ b/crates/tools/src/builder/map.rs @@ -364,7 +364,8 @@ impl BuilderMap { } /// What a merge needs of each input besides the map: its outline as drawn ([`node_outline`]) and -/// whether its fill closes its open subpaths (Consider Open Filled Paths as Closed, and a fill). +/// whether its fill closes its open subpaths (Consider Open Filled Paths as Closed, and a fill row +/// that draws). #[derive(Clone, Debug, PartialEq)] pub struct MergeInput { pub outline: Option, @@ -375,7 +376,7 @@ pub struct MergeInput { pub fn merge_inputs(leaves: &[&Node], open_filled_as_closed: bool) -> Vec { leaves .iter() - .map(|l| MergeInput { outline: node_outline(l).map(|o| o.0), fill_closes: open_filled_as_closed && !l.appearance.fill_paint().is_none() }) + .map(|l| MergeInput { outline: node_outline(l).map(|o| o.0), fill_closes: open_filled_as_closed && l.appearance.has_drawn_fill() }) .collect() } diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..5332bd5dd8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10109,7 +10109,8 @@ A canvas-click dialog also has `preview` (initially on): tentative settings draw ``` The Shape Builder tool's options are its Shape Builder Tool Options (dialog `shapeBuilderOptions`): -`openFilledAsClosed` (on: a filled open path bounds a region as if an invisible edge closed it; off: it cuts regions +`openFilledAsClosed` (on: a filled open path bounds a region as if an invisible edge closed it; a path counts as +filled when any of its fill rows is shown, painted and above 0% opacity, whichever row is on top; off: it cuts regions as an unfilled path does), `pickColorFrom` (`artwork`, the default: a merged shape takes the appearance, opacity and blending mode of the front-most selected object under the drag's first point, else of the one under its last point, else of the front-most selected object; `swatches`: the current fill, and the Left and Right arrow keys without diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..434d35de94 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1190,6 +1190,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [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) describes (`path.freehand` `connect`). Double-clicking the Shape Builder tool opens Shape Builder Tool Options ([Shape Builder tool options](https://web.archive.org/web/20260105172244/https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html)): with Consider Open Filled Paths as Closed off, a filled open path cuts regions as an unfilled one does; Pick Color From Color Swatches fills merged shapes with the current fill, the Left and Right arrow keys step it through the document's color swatches, and Cursor Swatch Preview shows the current fill and the swatches before and after it above the pointer; Selection Straight Line merges what the line from a drag's first point to its last crosses ([Setting Illustrator's Shape Builder Options](https://web.archive.org/web/20260615204453/https://creativepro.com/setting-illustrators-shape-builder-options/)); with Highlight Fill off, merge mode shades no regions (`shapeBuilder.merge` `openFilledAsClosed` and `pickColorFrom`). + As VectorCraft's own rule, Consider Open Filled Paths as Closed counts an open path as filled when any of its fill rows draws (shown, painted and above 0% opacity), whichever row is on top, as the canvas paints it: a painted fill under an unpainted top fill closes the path, and a painted fill that is hidden or at 0% opacity does not. The region map, merges, erasing and Clicking Stroke Splits the Path follow that rule, and the Shape Builder takes the faces and edges of a Live Paint group that one of their fill or stroke rows draws. Regression tests: `tests::build::shape_builder_closes_open_paths_by_the_fill_rows_that_draw`, `tests::build::shape_builder_stroke_split_closes_open_paths_by_the_fill_rows_that_draw` and `tests::build::shape_builder_takes_the_live_paint_faces_and_edges_that_draw`. With In Merge Mode, Clicking Stroke Splits the Path on in the same dialog, a click on a stroke in merge mode splits its path into the run of stroke under the pointer and the rest of the path, as the [Shape Builder tool options](https://web.archive.org/web/20260105172244/https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html) page describes; the run goes from the anchor or crossing point before the pointer to the one after it, and the pointer shows scissors over it. Highlight Stroke When Editable draws that run in the color chosen in its Color list (Red by default), and with Pick Color From Color Swatches the run takes the current fill as its stroke (`shapeBuilder.merge` `splitStroke`). As VectorCraft's own rule, OK in Shape Builder Tool Options gives an error for a highlight Color with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. Regression test: `dialogs::shape_builder_options::tests::a_highlight_color_with_an_alpha_part_is_refused`. With Pick Color From Artwork, a merged shape takes the appearance, opacity and blending mode of the object where the drag starts, else of the object where it ends, else of the front-most selected object, as the [Shape Builder combine page](https://web.archive.org/web/20251006211643/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/combine-shapes-with-shape-builder-tool.html) describes. A click in an area that no selected object covers, such as a hole other shapes enclose, takes the front-most selected object's look (`shapeBuilder.merge` `points`: the first point is where the drag starts, and the last point is where it ends). From 6be8f3095b807366e104a4e24c2927c63465c619 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:14:18 -0800 Subject: [PATCH 36/82] Metafile import: a clip that runs back over its own edges still clips A clip path is left out only when it is one rectangle around the whole picture. One figure through a box's four corners that runs back over its edges encloses less than the box, and the objects under such a clip are no longer imported unclipped. --- crates/metafile/src/import.rs | 11 ++++++++++- crates/metafile/src/tests/import.rs | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index dce77b2d7a..c87dd83d72 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -46,7 +46,8 @@ fn sane(r: Rect) -> bool { /// Is `path` one upright rectangle around the whole of `frame`? Writers clip to one first; it clips /// nothing in the picture. A path of more than one figure is not one rectangle, even when its -/// figures run along the sides of a box. +/// figures run along the sides of a box. Nor is one figure that runs back over its own edges, so +/// that it encloses less than the whole box. fn around(path: &BezPath, frame: Rect) -> bool { if path.elements().len() > 6 { return false; @@ -58,6 +59,7 @@ fn around(path: &BezPath, frame: Rect) -> bool { // neighbor (corners are in order around the box), never across it. let along = |i: usize, j: usize| (i + 4 - j) % 4 != 2; let mut seen = [false; 4]; + let mut points = Vec::with_capacity(6); let (mut first, mut last) = (None, None); for e in path.elements() { let (p, start) = match e { @@ -77,12 +79,19 @@ fn around(path: &BezPath, frame: Rect) -> bool { } last = Some(i); seen[i] = true; + points.push(corners[i]); } if let (Some(f), Some(l)) = (first, last) && !along(f, l) { return false; } + // Every edge runs along a side, so the figure winds the same number of times around every + // point inside the box: once, when its area is the box's. + let twice_area: f64 = points.iter().zip(points.iter().cycle().skip(1)).map(|(p, q)| (*p - corners[0]).cross(*q - corners[0])).sum(); + if (twice_area.abs() - 2.0 * b.area()).abs() > 1e-6 * (1.0 + 2.0 * b.area()) { + return false; + } let b = b.inflate(0.5, 0.5); seen.iter().all(|s| *s) && b.contains(frame.origin()) && b.contains(Point::new(frame.x1, frame.y1)) } diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index a1ddcc335e..55714c1f71 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -539,7 +539,11 @@ fn a_clip_whose_box_holds_the_picture_still_clips() { // Two open figures through the four corners, the top edge and the bottom edge, which enclose // nothing: MOVETOEX, LINETO, MOVETOEX, LINETO. let edges = vec![(27, le(&[-500, -500])), (54, le(&[5500, -500])), (27, le(&[5500, 3500])), (54, le(&[-500, 3500]))]; - for clip in [vec![(3, diamond)], vec![(3, bow_tie)], edges] { + // One figure through the four corners that runs back over its edges and encloses nothing: + // POLYLINE top-left, top-right, top-left, bottom-left, bottom-right, bottom-left. + let mut retraced = le(&[0, 0, 0, 0, 6]); + retraced.extend(le(&[-500, -500, 5500, -500, -500, -500, -500, 3500, 5500, 3500, -500, 3500])); + for clip in [vec![(3, diamond)], vec![(3, bow_tie)], edges, vec![(4, retraced)]] { let records = [vec![(59, vec![])], clip, vec![(60, vec![]), (67, le(&[5])), (43, le(&[0, 0, 5000, 3000]))]].concat(); let back = import(&hand_emf(5000, 3000, &records)).unwrap(); let layer = back.document.layers[0].children().unwrap(); From ef74b358f6fec261637b89a2e89022615769e6f8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:43:34 -0800 Subject: [PATCH 37/82] Docs: a clipping path that paints applies its fill and stroke effects after the group's The geometry effects of a clipping path's own fills and strokes stay live when the clipping path has a fill or stroke, so they reshape it after the geometry effects of the clip group and of the groups around it. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 385c6fa53a..c940da75e8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1609,7 +1609,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export, and type used as a clipping path keeps them when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. + The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export. When the clipping path has a fill or stroke of its own, the geometry effects of those fills and strokes apply after the group's geometry effects instead. Type used as a clipping path keeps its geometry effects when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. `doc::hit::tests` and the engine's `tests::clip` cover it. - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. From e001227ce1aab5140080d1f35e17e4a85c4e6691 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:29:00 -0800 Subject: [PATCH 38/82] Selection: effects on a hidden member of a clipping group leave clicks alone A clipping path that is a group clips by its visible members only. A geometry effect on a hidden member made clicks inside the clip group count the group's type by its glyph outlines, where they count it by its frame when nothing has an effect. Clicks now ask `effects::reshaped_clip_outline`, which gives a region only when an object the clipping path clips by has a geometry effect. `clip_outline` uses the same function. --- crates/effects/src/clip.rs | 12 ++++++++---- crates/effects/src/lib.rs | 2 +- crates/engine/src/session/documents.rs | 13 +------------ crates/engine/src/tests/clip.rs | 26 ++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 60cc3f2845..e210e097a9 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -12,10 +12,14 @@ 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) { - Some(m) => m.clip_outline(Some(&outline_text), &unite), - None => clip.clip_outline(Some(&outline_text), &unite), - } + reshaped_clip_outline(clip).unwrap_or_else(|| clip.clip_outline(Some(&outline_text), &unite)) +} + +/// [`clip_outline`] of a clipping path that a live geometry effect reshapes; `None` when nothing +/// `clip` clips by has a geometry effect of its own or on a fill or stroke. A hidden member of a +/// group or layer does not clip, so its effects are not counted. +pub fn reshaped_clip_outline(clip: &Node) -> Option> { + clip_effects_applied(clip, true).map(|m| m.clip_outline(Some(&outline_text), &unite)) } /// `n`, the clipping path of a clip group, with the live geometry effects of everything it clips by diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c18a31d842..2de6a1d142 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use adjust::{ is_channel, }; pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; -pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; +pub use clip::{WarpSeams, clip_outline, knockout_silhouette, reshaped_clip_outline, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a1a3fe5293..b4cecd44d5 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -25,7 +25,7 @@ impl Session { // Clicks find an image's own fills and strokes where their effects draw them. vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); // Clicks inside a clip group fall in the region its clipping path's effects give it. - vectorcraft_doc::hit::set_clip_region(reshaped_clip_region); + vectorcraft_doc::hit::set_clip_region(vectorcraft_render::effects::reshaped_clip_outline); // Clicks find a blend's steps and an envelope's distorted art where the canvas draws them, // evaluated again when the fonts, the hyphenation exceptions or the plug-ins change. vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || { @@ -493,17 +493,6 @@ impl Session { } } -/// The region clipping path `clip` clips to with its live geometry effects applied -/// ([`vectorcraft_render::effects::clip_outline`]), for clicks inside its clip group; `None` when -/// nothing in it has a visible geometry effect of its own or on a fill or stroke, so that clicks -/// count its plain shapes, type by its frame. -fn reshaped_clip_region(clip: &Node) -> Option> { - use vectorcraft_render::effects::has_geometry; - let mut reshaped = false; - clip.walk(&mut |n| reshaped |= has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(i.effects()))); - reshaped.then(|| vectorcraft_render::effects::clip_outline(clip)) -} - /// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased. fn sets_in_any(t: &TextObject, names: &HashSet) -> bool { !names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase())) diff --git a/crates/engine/src/tests/clip.rs b/crates/engine/src/tests/clip.rs index 7d500f6835..87af67d95d 100644 --- a/crates/engine/src/tests/clip.rs +++ b/crates/engine/src/tests/clip.rs @@ -128,6 +128,32 @@ fn text_on_top_clips_by_its_glyphs() { assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_))); } +#[test] +fn a_hidden_member_of_a_clipping_group_leaves_clicks_on_the_type_frame() { + // The clipping path is a group of an "O" and a hidden rectangle with a Transform effect. The + // hidden rectangle does not clip, so clicks inside the clip group still count the type by its + // frame, the counter of the "O" included. + let mut s = session(); + let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let t = id_of(&s.execute("text.create", &json!({"x": 5, "y": 80, "text": "O", "size": 90})).unwrap()); + let b = s.doc().unwrap().doc.node(t).unwrap().geometric_bounds().unwrap(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 10, "height": 10})).unwrap()); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 20}, "ids": [r.0], "item": null})).unwrap(); + select(&mut s, &[art, t]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + // Make Clipping Mask takes no group on top, so the group goes in place of the type. + let mut d = (*s.doc().unwrap().doc).clone(); + let mut rect = (*d.remove(r).unwrap()).clone(); + rect.visible = false; + let text = d.remove(t).unwrap(); + let group = vectorcraft_doc::Node::group(d.alloc_id(), vec![text, Arc::new(rect)]); + d.insert(Some(g), 0, group).unwrap(); + let leaf = |x: f64, y: f64| vectorcraft_doc::hit::hit_test(&d, vectorcraft_geom::Point::new(x, y), Default::default()).map(|h| h.leaf); + let middle = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); + assert_eq!(leaf(middle.0, middle.1), Some(art), "in the counter, inside the frame"); + assert_eq!(leaf(b.x1 + 3.0, middle.1), None, "beside the text"); +} + #[test] fn clicks_follow_the_region_a_clipping_paths_effects_give_it() { // Art from 10 to 90 pt, clipped by the artboard's left half, which a Transform effect moves diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c940da75e8..d23d645f3f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1610,7 +1610,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export. When the clipping path has a fill or stroke of its own, the geometry effects of those fills and strokes apply after the group's geometry effects instead. Type used as a clipping path keeps its geometry effects when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. - Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. `doc::hit::tests` and the engine's `tests::clip` cover it. + Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. A hidden member of a group used as a clipping path does not clip, and a geometry effect on it does not change where clicks land. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. `doc::hit::tests` and the engine's `tests::clip` cover it. - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. From 3de44da6a6274eb38986bf3692936d4427ad7b4e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:37:49 -0800 Subject: [PATCH 39/82] Image Trace: a gray photo with little noise counts as continuous tone The continuous-tone check exempts a neutral or single-curve picture when sixteen colors leave at most a fifth of the error of eight. The exemption is for flat art of up to sixteen clean levels, where sixteen colors leave no error. A gray photo with 0 to 2 levels of noise measures 0.19, under the cutoff, so it was classed as flat art and traced without smoothing, while its color original and the same photo with more noise were smoothed. The cutoff is now a tenth (VectorCraft's own value). Clean gray, sepia, single-hue and duotone art of up to sixteen levels stays flat art. Docs: docs/target-app-parity.md. --- crates/trace/src/quantize/cut_edge.rs | 6 +++--- crates/trace/src/tests.rs | 10 ++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 27b7727b38..658d280eb9 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -284,7 +284,7 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b /// stay more than 0.065 as far as a single color does. So is a sepia, single-hue or duotone /// picture whose tones follow one curve through the color cube (19 in 20 of its opaque pixels on /// it, see [`on_one_curve`]). Neither is, however far eight colors stay, when sixteen colors stay -/// at most a fifth as far as eight do. That exception holds for flat art of up to sixteen clean +/// at most a tenth as far as eight do. That exception holds for flat art of up to sixteen clean /// levels, gray or tinted, where sixteen colors come within about a level of every pixel. On gray /// and tinted photos sixteen colors stay 0.18 to 0.57 as far as eight do, and on most anti-aliased /// or noisy gray flat art 0.24 to 0.5 as far, so the exception holds for neither. @@ -323,9 +323,9 @@ pub(crate) fn continuous_tone(img: &Raster) -> bool { } let one_line = || 20 * neutral >= 19 * opaque || 20 * on_one_curve(img) >= 19 * opaque; if eight > 0.1 * one { - return !(unsaid(16) <= 0.2 * eight && one_line()); + return !(unsaid(16) <= 0.1 * eight && one_line()); } - unsaid(16) > 0.2 * eight && one_line() + unsaid(16) > 0.1 * eight && one_line() } /// How far (levels) a pixel may lie from the tone curve of its picture and still count as on it, diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 2bf9e1e2b6..8e2f04e286 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -2009,6 +2009,16 @@ fn a_gray_photo_is_continuous_tone_as_its_color_original_is() { } } +/// A gray photo with little or no noise (a render, a denoised scan) is continuous tone, as its +/// color original is. Sixteen colors stay 0.19 as far from its pixels as eight do. +#[test] +fn a_clean_gray_photo_is_continuous_tone_as_its_color_original_is() { + for noise in [0.0, 1.0, 2.0, 3.0] { + assert!(quantize::continuous_tone(&photo_scene(noise, false)), "color, noise {noise}"); + assert!(quantize::continuous_tone(&photo_scene(noise, true)), "gray, noise {noise}"); + } +} + /// Sepia: dark brown to cream. const SEPIA: [[f64; 3]; 2] = [[45.0, 28.0, 15.0], [250.0, 235.0, 205.0]]; /// A single blue hue: navy to pale blue. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..c12b11596f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1436,6 +1436,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a fifth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. - **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a fifth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. - **Image Trace, flat art of 10 to 16 levels:** the exception for 16 colors leaving at most a fifth of the error of 8 also applies to a neutral or tinted picture whose 8-color error is more than 0.1 of the error of one color (VectorCraft's own values), so Color and Grayscale trace clean gray, sepia, single-hue and duotone art of 10 to 16 evenly spaced levels as flat art, without smoothing it first. At 16 Colors, 16 gray or sepia levels crossed by two 1 px lines trace as the 35 regions the lines cut them into instead of 33, and Automatic traces 16 clean sepia levels in 9 colors instead of 4. Tests: `tests::flat_art_of_up_to_sixteen_clean_levels_stays_flat_art`, `tests::thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced`. Scores and hours unchanged. +- **Image Trace, clean gray photos:** the exception for flat art of up to 16 clean levels now applies when 16 colors leave at most a tenth of the error of 8, instead of a fifth (VectorCraft's own values). On the synthetic gray photo with 0 to 2 levels of noise, 16 colors leave 0.19 of the error of 8, so Color and Grayscale now count it as continuous tone and smooth it before tracing, as they do its color original. Clean gray, sepia, single-hue and duotone art of up to 16 levels, where 16 colors leave no error, stays on the flat-art route. Test: `tests::a_clean_gray_photo_is_continuous_tone_as_its_color_original_is`. Scores and hours unchanged. - **Image Trace, JPEG blocks:** Color, Grayscale and Black and White traces smooth the edges of a JPEG's 8 × 8 blocks before tracing, so a dark ground whose tone changes slowly is no longer traced as staircases along the block edges. The block grid is found in the image itself: the mean step between neighboring pixels must peak at one position in eight, across and down. A cropped JPEG and a JPEG saved in another format are smoothed too. A picture of flat 8 × 8 cells is left as it is, and art made of 8 × 8 cells with slight texture inside each cell is smoothed as a JPEG copy would be. A small step at a block edge, with no edge in the eight pixels around it, is spread over those eight pixels, and larger steps are kept, as the deblocking filters of ITU-T H.263 Annex J and MPEG-4 Part 2 Annex F.3.1 do. Black and White smooths only the smallest steps, and traces of more than 16 colors move every row of a stretch of eight by the same amount (VectorCraft's own rule and values). On a 4000 × 3000 photo saved as JPEG, the straight outline segments that lie on the block grid fall from 52% and 51% (across and down) to 24% and 29% at 16 Colors, and from 54% and 51% to 43% and 42% at High Fidelity Photo; staircase runs fall from 83 to 67 (16 Colors), 11 to 1 (6 Colors), 30 to 13 (Black and White Logo) and 428 to 403 (High Fidelity Photo). A 600 px photo saved as a quality-40 JPEG has 4 staircase runs instead of 2 at 16 Colors. Images without a block grid trace as before. Tests: `tests::a_jpegs_blocks_are_not_traced_as_steps`, `deblock::tests`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. - **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. From 04df80f4e5bc2b6ff44627e1921f0a700dc90ce6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:42:56 -0800 Subject: [PATCH 40/82] Docs: the Image Trace evidence lines give the continuous-tone cutoff as a tenth --- docs/target-app-parity.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c12b11596f..193801baf6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1433,9 +1433,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. With Transform Objects off, a member's mask takes the effect's stroke and pattern changes as the member does. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::a_transform_of_strokes_only_scales_a_members_mask_strokes`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. -- **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a fifth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. -- **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a fifth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. -- **Image Trace, flat art of 10 to 16 levels:** the exception for 16 colors leaving at most a fifth of the error of 8 also applies to a neutral or tinted picture whose 8-color error is more than 0.1 of the error of one color (VectorCraft's own values), so Color and Grayscale trace clean gray, sepia, single-hue and duotone art of 10 to 16 evenly spaced levels as flat art, without smoothing it first. At 16 Colors, 16 gray or sepia levels crossed by two 1 px lines trace as the 35 regions the lines cut them into instead of 33, and Automatic traces 16 clean sepia levels in 9 colors instead of 4. Tests: `tests::flat_art_of_up_to_sixteen_clean_levels_stays_flat_art`, `tests::thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced`. Scores and hours unchanged. +- **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a tenth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. +- **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a tenth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. +- **Image Trace, flat art of 10 to 16 levels:** the exception for 16 colors leaving at most a tenth of the error of 8 also applies to a neutral or tinted picture whose 8-color error is more than 0.1 of the error of one color (VectorCraft's own values), so Color and Grayscale trace clean gray, sepia, single-hue and duotone art of 10 to 16 evenly spaced levels as flat art, without smoothing it first. At 16 Colors, 16 gray or sepia levels crossed by two 1 px lines trace as the 35 regions the lines cut them into instead of 33, and Automatic traces 16 clean sepia levels in 9 colors instead of 4. Tests: `tests::flat_art_of_up_to_sixteen_clean_levels_stays_flat_art`, `tests::thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced`. Scores and hours unchanged. - **Image Trace, clean gray photos:** the exception for flat art of up to 16 clean levels now applies when 16 colors leave at most a tenth of the error of 8, instead of a fifth (VectorCraft's own values). On the synthetic gray photo with 0 to 2 levels of noise, 16 colors leave 0.19 of the error of 8, so Color and Grayscale now count it as continuous tone and smooth it before tracing, as they do its color original. Clean gray, sepia, single-hue and duotone art of up to 16 levels, where 16 colors leave no error, stays on the flat-art route. Test: `tests::a_clean_gray_photo_is_continuous_tone_as_its_color_original_is`. Scores and hours unchanged. - **Image Trace, JPEG blocks:** Color, Grayscale and Black and White traces smooth the edges of a JPEG's 8 × 8 blocks before tracing, so a dark ground whose tone changes slowly is no longer traced as staircases along the block edges. The block grid is found in the image itself: the mean step between neighboring pixels must peak at one position in eight, across and down. A cropped JPEG and a JPEG saved in another format are smoothed too. A picture of flat 8 × 8 cells is left as it is, and art made of 8 × 8 cells with slight texture inside each cell is smoothed as a JPEG copy would be. A small step at a block edge, with no edge in the eight pixels around it, is spread over those eight pixels, and larger steps are kept, as the deblocking filters of ITU-T H.263 Annex J and MPEG-4 Part 2 Annex F.3.1 do. Black and White smooths only the smallest steps, and traces of more than 16 colors move every row of a stretch of eight by the same amount (VectorCraft's own rule and values). On a 4000 × 3000 photo saved as JPEG, the straight outline segments that lie on the block grid fall from 52% and 51% (across and down) to 24% and 29% at 16 Colors, and from 54% and 51% to 43% and 42% at High Fidelity Photo; staircase runs fall from 83 to 67 (16 Colors), 11 to 1 (6 Colors), 30 to 13 (Black and White Logo) and 428 to 403 (High Fidelity Photo). A 600 px photo saved as a quality-40 JPEG has 4 staircase runs instead of 2 at 16 Colors. Images without a block grid trace as before. Tests: `tests::a_jpegs_blocks_are_not_traced_as_steps`, `deblock::tests`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. From 11bd1659f5a5b6726cd82ced6e6ef9c73f1a2ca2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:51:22 -0800 Subject: [PATCH 41/82] Selection: symbol parts follow the region a clipping path's effects give it Direct Selection on a dynamic symbol's instance picked an object in the region its clipping path's effects give it, but cut the object's outline to the plain clipping path, so the outline came out empty or cut short. In a layer of the art whose Preview is off, it tested the effect-shaped region and the fills, where the canvas draws the plain clipping path and outlines. - `hit::symbol_part_outline` cuts to the bounds of the effect-shaped region. The new `hit::symbol_part_outline_in_view` takes the view's outline state, and the Direct Selection overlay passes it. - `hit::symbol_part_at` tests art in a layer whose Preview is off in outline. --- crates/doc/src/hit.rs | 67 +++++++++++++++++++++++++------------ crates/doc/src/hit/tests.rs | 44 ++++++++++++++++++++++++ crates/tools/src/direct.rs | 4 +-- docs/target-app-parity.md | 2 +- 4 files changed, 92 insertions(+), 25 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index c0a80a8964..d6888eb065 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1202,7 +1202,9 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// group's original shapes, which the canvas does not draw, and guide paths with /// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice /// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not -/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. +/// draw. Art in a layer of the symbol whose Preview is off is tested in outline +/// ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or +/// no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { struct Find<'a> { doc: &'a Document, @@ -1212,35 +1214,34 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> symbols: &'a SymbolBoxes<'a>, } impl Find<'_> { - fn hit(&self, n: &Node, whole: Option) -> bool { - self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> bool { + self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, opt, self.symbols).is_some()) } fn skips_guide(&self, n: &Node) -> bool { self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) } - fn find(&self, n: &Node, path: &mut Vec, whole: Option) -> bool { + /// `outline`: the view is in Outline, or a layer around `n` has Preview off. + fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> bool { if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { return false; } - let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) }; + let outline = outline || previews_off(n); + let opt = HitOptions { outline, ..self.opt }; + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { - return self.hit(n, whole); + return self.hit(n, whole, opt); } let whole = self.art.whole(n, self.doc, whole); if n.clips() && let Some(first) = children.first() { let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; - if self.opt.clip_outlines - && first.visible - && !first.locked - && !self.skips_guide(first) - && clip_outline(&clip, self.p, self.opt).is_some() + if self.opt.clip_outlines && first.visible && !first.locked && !self.skips_guide(first) && clip_outline(&clip, self.p, opt).is_some() { path.push(0); return true; } - if !clip_contains(&clip, self.p, self.opt.outline) { + if !clip_contains(&clip, self.p, outline) { return false; } } @@ -1251,7 +1252,7 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> } let Ok(i) = u32::try_from(i) else { continue }; path.push(i); - if self.find(c, path, whole) { + if self.find(c, path, whole, outline) { return true; } path.pop(); @@ -1262,7 +1263,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 { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None, opt.outline).then_some(path) } /// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), @@ -1271,9 +1272,17 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> /// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An /// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut /// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`] -/// picks it. +/// picks it: the bounds of the region its live geometry effects give it ([`clip_region`]) when it +/// has any. /// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { + symbol_part_outline_in_view(doc, inst, path, false) +} + +/// [`symbol_part_outline`] in the view the canvas shows: with `outline` (Outline view), and below a +/// layer of the art whose Preview is off, the outlines are cut to the bounds of the plain clipping +/// paths, without their live effects, as the clicks of [`HitOptions::outline`] count them. +pub fn symbol_part_outline_in_view(doc: &Document, inst: &Node, path: &[u32], mut outline: bool) -> Option { let art = InstanceArt::of(doc, inst)?; let part = crate::symbol_overrides::part(&art.art, path)?; let mut out = BezPath::new(); @@ -1283,10 +1292,11 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< for &i in path { let children = n.children()?; whole = art.whole(n, doc, whole); + outline = outline || previews_off(n); if i != 0 && n.clips() && let Some(first) = children.first() - && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + && let Some(clip) = clip_limit(&art.place_unclipped(first, doc, whole)?, outline) { match meet(Some(clip), limit) { Some(r) => limit = Some(r), @@ -1295,12 +1305,24 @@ 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, doc, whole)?, limit, outline, &mut out); Some(out) } -/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one. -fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { +/// The box that the art clipped by `clip` is drawn in: the bounds of the region the live geometry +/// effects of `clip` give it ([`clip_region`]), or the bounds of `clip` when it has no such effects +/// and with `outline`, where the canvas draws no effects. +fn clip_limit(clip: &Node, outline: bool) -> Option { + use vectorcraft_geom::kurbo::Shape; + let region = if outline { None } else { clip_region(clip) }; + match region.as_deref() { + Some(Some((bp, _))) if !bp.elements().is_empty() => Some(bp.bounding_box()), + _ => clip.geometric_bounds(), + } +} + +/// [`symbol_part_outline_in_view`] for placed object `n`, cut to `limit` when there is one. +fn outline_within(n: &Node, limit: Option, outline: bool, out: &mut BezPath) { use vectorcraft_geom::kurbo::Shape; if let Some(pd) = n.path_data() { match limit { @@ -1314,20 +1336,21 @@ fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { out.extend(b.to_path(0.1)); } let Some(children) = n.children() else { return }; + let outline = outline || previews_off(n); if n.clips() && let Some((first, rest)) = children.split_first() { - let inner = match first.geometric_bounds() { + let inner = match clip_limit(first, outline) { Some(c) => match meet(Some(c), limit) { Some(r) => Some(r), None => return, }, None => limit, }; - rest.iter().for_each(|c| outline_within(c, inner, out)); + rest.iter().for_each(|c| outline_within(c, inner, outline, out)); return; } - children.iter().for_each(|c| outline_within(c, limit, out)); + children.iter().for_each(|c| outline_within(c, limit, outline, out)); } /// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap. diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 4584421200..e0c2f8de4d 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1788,6 +1788,50 @@ fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}"); } +/// In a dynamic symbol's art, an object in a clipping layer whose clipping path has a live effect +/// is picked and outlined in the region the effect gives the clipping path. In Outline view, and +/// when the layer's Preview is off, the plain clipping path counts, as the canvas draws it. +#[test] +fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() { + set_clip_region(moved_clip_region); + let instance = |preview: bool| { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mut mask, art) = half_clipped(&mut d); + mask.appearance.effects.push(test_move()); + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, preview: p, .. } = &mut layer.kind { + (*children, *p) = (vec![Arc::new(mask), Arc::new(art)], preview); + } + layer.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + (d, inst) + }; + let outline_box = |d: &Document, inst: NodeId, outline: bool| { + let o = symbol_part_outline_in_view(d, d.node(inst).unwrap(), &[0, 1], outline).unwrap(); + vectorcraft_geom::kurbo::Shape::bounding_box(&o) + }; + let opt = HitOptions::default(); + let (d, inst) = instance(true); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), Some(vec![0, 1]), "in the moved region"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), None, "where the clipping path was"); + let b = outline_box(&d, inst, false); + assert!((b.x0 - 100.0).abs() < 1e-6 && (b.x1 - 200.0).abs() < 1e-6, "the outline is cut to the moved region: {b:?}"); + let b = outline_box(&d, inst, true); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "in Outline view, to the plain clipping path: {b:?}"); + // With the layer's Preview off, the art's top edge counts within the plain clipping path. + let (d, inst) = instance(false); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 0.5), opt), Some(vec![0, 1]), "on the edge the canvas draws"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 0.5), opt), None, "past the plain clipping path"); + let b = outline_box(&d, inst, false); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "Preview off: the plain clipping path: {b:?}"); +} + /// In a dynamic symbol's art that placing through its 9-slice guides could make more than /// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the /// canvas draws nothing of it. At the art's natural size the same art is picked. diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index cee2c7dfa1..ebcbeb4489 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -55,7 +55,7 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at, symbol_part_at, symbol_part_outline}; +use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at, symbol_part_at, symbol_part_outline_in_view}; use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection, symbol_overrides}; use vectorcraft_geom::{Anchor, PathData, PathSeg, Point, Rect, Vec2}; @@ -1029,7 +1029,7 @@ impl Tool for DirectSelectionTool { out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id))); if !self.group { if let Some(sp) = &cx.selection.symbol_part - && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline(cx.doc, n, &sp.path)) + && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline_in_view(cx.doc, n, &sp.path, cx.outline)) { out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false }); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d23d645f3f..1a1462788a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1610,7 +1610,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export. When the clipping path has a fill or stroke of its own, the geometry effects of those fills and strokes apply after the group's geometry effects instead. Type used as a clipping path keeps its geometry effects when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. - Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. A hidden member of a group used as a clipping path does not clip, and a geometry effect on it does not change where clicks land. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. `doc::hit::tests` and the engine's `tests::clip` cover it. + Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. A hidden member of a group used as a clipping path does not clip, and a geometry effect on it does not change where clicks land. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. Direct Selection picks and outlines an object of a dynamic symbol's art by the same rules. `doc::hit::tests` and the engine's `tests::clip` cover it. - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. From 1d94c974251ba3931fc411bea7570158eeefbc5e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:30:12 -0800 Subject: [PATCH 42/82] Selection: symbol parts count the Preview of the instance's layers Direct Selection on a dynamic symbol's instance below a layer whose Preview is off tested and outlined the art in Preview, where the canvas draws it in outline with the plain clipping paths. When a clipping path's effects left no region, a selected object in its clip group kept an outline cut to the plain clipping path. - `hit::symbol_part_at` and `hit::symbol_part_outline_in_view` count the layers around the instance, the isolated container and the layers around it apart, as clicks outside symbols do. `symbol_part_outline_in_view` takes the click options in place of the outline flag. - The outline of an object is empty when the effects of a clipping path around it leave no region. --- crates/doc/src/hit.rs | 63 ++++++++++++++++++++++--------------- crates/doc/src/hit/tests.rs | 56 ++++++++++++++++++++++----------- crates/tools/src/direct.rs | 2 +- 3 files changed, 76 insertions(+), 45 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index d6888eb065..dcb68378d1 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1202,8 +1202,8 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// group's original shapes, which the canvas does not draw, and guide paths with /// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice /// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not -/// draw. Art in a layer of the symbol whose Preview is off is tested in outline -/// ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or +/// draw. Art in a layer of the symbol whose Preview is off, and the art of an instance below such a +/// layer, is tested in outline ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or /// no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { struct Find<'a> { @@ -1263,7 +1263,17 @@ 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, opt.outline).then_some(path) + let outline = opt.outline || below_preview_off(doc, inst, opt.scope); + Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None, outline).then_some(path) +} + +/// Is `n` below a layer whose Preview is off, so that the canvas draws it in outline? As for clicks +/// ([`path_options`]), the Preview of the isolated container `scope` and of the layers around it +/// does not count. +fn below_preview_off(doc: &Document, n: &Node, scope: Option) -> bool { + let Some(chain) = doc.ancestry(n.id) else { return false }; + let below = scope.and_then(|s| chain.iter().position(|a| *a == s)).map_or(0, |i| i + 1); + chain.iter().skip(below).any(|a| doc.node(*a).is_some_and(previews_off)) } /// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), @@ -1276,15 +1286,19 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> /// has any. /// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { - symbol_part_outline_in_view(doc, inst, path, false) + symbol_part_outline_in_view(doc, inst, path, HitOptions::default()) } -/// [`symbol_part_outline`] in the view the canvas shows: with `outline` (Outline view), and below a -/// layer of the art whose Preview is off, the outlines are cut to the bounds of the plain clipping -/// paths, without their live effects, as the clicks of [`HitOptions::outline`] count them. -pub fn symbol_part_outline_in_view(doc: &Document, inst: &Node, path: &[u32], mut outline: bool) -> Option { +/// [`symbol_part_outline`] in the view the canvas shows, for the clicks of `opt`: in Outline view +/// ([`HitOptions::outline`]) and below a layer whose Preview is off, in the art or around the +/// instance (the isolated container [`HitOptions::scope`] and the layers around it apart), the +/// outlines are cut to the bounds of the plain clipping paths, without their live effects, as +/// [`symbol_part_at`] counts them. The outline is empty when the effects of a clipping path around +/// the object leave no region. +pub fn symbol_part_outline_in_view(doc: &Document, inst: &Node, path: &[u32], opt: HitOptions) -> Option { let art = InstanceArt::of(doc, inst)?; let part = crate::symbol_overrides::part(&art.art, path)?; + let mut outline = opt.outline || below_preview_off(doc, inst, opt.scope); let mut out = BezPath::new(); let mut limit = None; let mut whole = None; @@ -1296,12 +1310,9 @@ pub fn symbol_part_outline_in_view(doc: &Document, inst: &Node, path: &[u32], mu if i != 0 && n.clips() && let Some(first) = children.first() - && let Some(clip) = clip_limit(&art.place_unclipped(first, doc, whole)?, outline) { - match meet(Some(clip), limit) { - Some(r) => limit = Some(r), - None => return Some(out), - } + let Some(within) = limit_within(&art.place_unclipped(first, doc, whole)?, limit, outline) else { return Some(out) }; + limit = within; } n = children.get(usize::try_from(i).ok()?)?; } @@ -1309,15 +1320,21 @@ pub fn symbol_part_outline_in_view(doc: &Document, inst: &Node, path: &[u32], mu Some(out) } -/// The box that the art clipped by `clip` is drawn in: the bounds of the region the live geometry -/// effects of `clip` give it ([`clip_region`]), or the bounds of `clip` when it has no such effects -/// and with `outline`, where the canvas draws no effects. -fn clip_limit(clip: &Node, outline: bool) -> Option { +/// `limit` cut to the box that the art clipped by `clip` is drawn in: the bounds of the region the +/// live geometry effects of `clip` give it ([`clip_region`]), or the bounds of `clip` when it has no +/// such effects and with `outline`, where the canvas draws no effects. `None` when none of that art +/// is drawn: the effects leave no region, or the box is outside `limit`. +fn limit_within(clip: &Node, limit: Option, outline: bool) -> Option> { use vectorcraft_geom::kurbo::Shape; let region = if outline { None } else { clip_region(clip) }; - match region.as_deref() { + let bounds = match region.as_deref() { Some(Some((bp, _))) if !bp.elements().is_empty() => Some(bp.bounding_box()), - _ => clip.geometric_bounds(), + Some(_) => return None, + None => clip.geometric_bounds(), + }; + match bounds { + Some(b) => meet(Some(b), limit).map(Some), + None => Some(limit), } } @@ -1340,13 +1357,7 @@ fn outline_within(n: &Node, limit: Option, outline: bool, out: &mut BezPat if n.clips() && let Some((first, rest)) = children.split_first() { - let inner = match clip_limit(first, outline) { - Some(c) => match meet(Some(c), limit) { - Some(r) => Some(r), - None => return, - }, - None => limit, - }; + let Some(inner) = limit_within(first, limit, outline) else { return }; rest.iter().for_each(|c| outline_within(c, inner, outline, out)); return; } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index e0c2f8de4d..4fadb84c86 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -111,10 +111,17 @@ fn half_clipped(d: &mut Document) -> (Node, Node) { } /// A stand-in for the engine's [`ClipRegion`]: with an effect named `test.move` in the clipping -/// path, its first shape moved 100 pt right; without one, no geometry effect. +/// path, its first shape moved 100 pt right; with one named `test.empty`, no region left to clip +/// by; without either, no geometry effect. fn moved_clip_region(clip: &Node) -> Option> { - let mut moves = false; - clip.walk(&mut |n| moves |= n.appearance.effects.iter().any(|e| e.visible && e.id == "test.move")); + let (mut moves, mut empty) = (false, false); + clip.walk(&mut |n| { + moves |= n.appearance.effects.iter().any(|e| e.visible && e.id == "test.move"); + empty |= n.appearance.effects.iter().any(|e| e.visible && e.id == "test.empty"); + }); + if empty { + return Some(None); + } if !moves { return None; } @@ -1790,18 +1797,24 @@ fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { /// In a dynamic symbol's art, an object in a clipping layer whose clipping path has a live effect /// is picked and outlined in the region the effect gives the clipping path. In Outline view, and -/// when the layer's Preview is off, the plain clipping path counts, as the canvas draws it. +/// when Preview is off for the layer in the art or for the layer the instance is on, the plain +/// clipping path counts, as the canvas draws it. An effect that leaves no region leaves no outline. #[test] fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() { set_clip_region(moved_clip_region); - let instance = |preview: bool| { + // The instance of a symbol whose art is a clipping layer; `preview` is the Preview of that + // layer and of the document layer the instance is on. + let instance = |effect: &str, preview: [bool; 2]| { let mut d = Document::new(300.0, 200.0); + if let NodeKind::Layer { preview: p, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind { + *p = preview[1]; + } let l = d.layers[0].id; let (mut mask, art) = half_clipped(&mut d); - mask.appearance.effects.push(test_move()); + mask.appearance.effects.push(crate::Effect { id: effect.into(), ..test_move() }); let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); if let NodeKind::Layer { children, preview: p, .. } = &mut layer.kind { - (*children, *p) = (vec![Arc::new(mask), Arc::new(art)], preview); + (*children, *p) = (vec![Arc::new(mask), Arc::new(art)], preview[0]); } layer.set_clips(true); let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); @@ -1810,26 +1823,33 @@ fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() { d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); (d, inst) }; - let outline_box = |d: &Document, inst: NodeId, outline: bool| { - let o = symbol_part_outline_in_view(d, d.node(inst).unwrap(), &[0, 1], outline).unwrap(); + let outline_box = |d: &Document, inst: NodeId, opt: HitOptions| { + let o = symbol_part_outline_in_view(d, d.node(inst).unwrap(), &[0, 1], opt).unwrap(); vectorcraft_geom::kurbo::Shape::bounding_box(&o) }; let opt = HitOptions::default(); - let (d, inst) = instance(true); + let (d, inst) = instance("test.move", [true, true]); let n = d.node(inst).unwrap(); assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), Some(vec![0, 1]), "in the moved region"); assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), None, "where the clipping path was"); - let b = outline_box(&d, inst, false); + let b = outline_box(&d, inst, opt); assert!((b.x0 - 100.0).abs() < 1e-6 && (b.x1 - 200.0).abs() < 1e-6, "the outline is cut to the moved region: {b:?}"); - let b = outline_box(&d, inst, true); + let b = outline_box(&d, inst, HitOptions { outline: true, ..opt }); assert!(b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "in Outline view, to the plain clipping path: {b:?}"); - // With the layer's Preview off, the art's top edge counts within the plain clipping path. - let (d, inst) = instance(false); + // With Preview off, the art's top edge counts within the plain clipping path. + for (preview, what) in [([false, true], "the layer in the art"), ([true, false], "the layer the instance is on")] { + let (d, inst) = instance("test.move", preview); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 0.5), opt), Some(vec![0, 1]), "on the edge the canvas draws, Preview off for {what}"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 0.5), opt), None, "past the plain clipping path, Preview off for {what}"); + let b = outline_box(&d, inst, opt); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "Preview off for {what}: the plain clipping path: {b:?}"); + } + // An effect that leaves the clipping path no region hides the art: no pick and no outline. + let (d, inst) = instance("test.empty", [true, true]); let n = d.node(inst).unwrap(); - assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 0.5), opt), Some(vec![0, 1]), "on the edge the canvas draws"); - assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 0.5), opt), None, "past the plain clipping path"); - let b = outline_box(&d, inst, false); - assert!(b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "Preview off: the plain clipping path: {b:?}"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), None); + assert!(symbol_part_outline(&d, n, &[0, 1]).unwrap().elements().is_empty(), "nothing is drawn, so nothing is outlined"); } /// In a dynamic symbol's art that placing through its 9-slice guides could make more than diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index ebcbeb4489..69230dc050 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1029,7 +1029,7 @@ impl Tool for DirectSelectionTool { out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id))); if !self.group { if let Some(sp) = &cx.selection.symbol_part - && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline_in_view(cx.doc, n, &sp.path, cx.outline)) + && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline_in_view(cx.doc, n, &sp.path, cx.hit_options())) { out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false }); } From 9d8407b3deb2cca3235886615b43a0cdb38782fb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:45:04 -0800 Subject: [PATCH 43/82] Metafile import: metric WMFs are read at 96 pixels an inch, and only round solid strokes join A WMF without a usable placeable box that draws in a metric map mode (MM_LOMETRIC to MM_TWIPS) is read at 96 pixels an inch, the scale craft_metafile plays those modes at, so a one-inch MM_TWIPS picture opens at 72 pt instead of 4.8 pt. The mode that more of the drawing records the reader plays are drawn in decides, with SAVEDC and RESTOREDC followed as the reader follows them; a file that draws nothing goes by its last mode. Opaque strokes drawn one after another now join into one path only when the pen is solid with round caps and round joins. A dashed stroke keeps its own dash phase, and flat or square caps and miter or bevel joins stay as the separate strokes drew them. --- crates/metafile/src/import/picture.rs | 102 +++++++++++++++++++++++--- crates/metafile/src/tests/import.rs | 87 ++++++++++++++++++++++ 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index 4689572a62..5385d94153 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -2,8 +2,9 @@ //! picture, and the picture's items become document objects here. //! //! - A filled or stroked path becomes a path with a fill and a stroke (caps, joins, miter limit -//! and dashes from the pen). Opaque strokes drawn one after another, each starting where the -//! last one ended, with the same pen and clip, join into one path. +//! and dashes from the pen). Opaque solid strokes with round caps and round joins drawn one after +//! another, each starting where the last one ended, with the same pen and clip, join into one +//! path. Other strokes stay apart, each with its own caps and its own dashes. //! - A path filled with a pattern or hatched brush is filled with the average color of the //! brush's tile. //! - A bitmap becomes an image (its pixels as PNG) placed where the picture draws it, turned or @@ -14,8 +15,10 @@ //! the whole picture clips nothing and is left out. //! //! A WMF without a placeable header is read at 1440 units an inch, and one whose placeable box is -//! empty or inverted at the units an inch the header gives. An EMF without a usable frame is read -//! at its device's pixels a millimetre (96 pixels an inch when the header doesn't say). +//! empty or inverted at the units an inch the header gives. Either one that draws in a metric map +//! mode (MM_LOMETRIC to MM_TWIPS) is read at 96 pixels an inch, as `craft_metafile` plays those +//! modes. An EMF without a usable frame is read at its device's pixels a millimetre (96 +//! pixels an inch when the header doesn't say). use std::collections::HashMap; use std::sync::{Arc, Weak}; @@ -46,11 +49,79 @@ const READER_ITEMS: usize = 200_000; /// Points a unit of a WMF without a placeable header (1440 units an inch). const WMF_UNIT: f64 = 72.0 / 1440.0; +/// The SAVEDC levels `craft_metafile` keeps; it stops reading at a SAVEDC past them. +const SAVED_LEVELS: usize = 1024; +/// Points a pixel at 96 pixels an inch. +const PT_PER_PX: f64 = 72.0 / 96.0; /// Points a millimetre. const PT_PER_MM: f64 = 72.0 / 25.4; +/// WMF records that draw and that `craft_metafile` plays: lines, shapes, text, PATBLT and bitmaps. +const WMF_DRAWS: [u16; 16] = + [0x0213, 0x0324, 0x0325, 0x0418, 0x041B, 0x0521, 0x0538, 0x061C, 0x061D, 0x0817, 0x081A, 0x0830, 0x0940, 0x0A32, 0x0B41, 0x0F43]; + +/// Does a WMF draw in a metric map mode (MM_LOMETRIC to MM_TWIPS)? `craft_metafile` plays those +/// at 96 pixels an inch, whatever the window. The mode that more drawing records are played in +/// decides; a file that draws nothing goes by its last mode. `start` is where the standard header +/// begins. +fn wmf_metric(bytes: &[u8], start: usize) -> bool { + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let Some(words) = start.checked_add(2).and_then(u16_at) else { return false }; + let mut at = start.saturating_add(usize::from(words) * 2); + let mut mode = 1; + let metric = |mode: u16| (2..=6).contains(&mode); + // Drawing records played in a metric mode, and in any other. + let (mut in_metric, mut in_other) = (0usize, 0usize); + // The modes SAVEDC kept, which RESTOREDC brings back as the reader does. + let mut saved: Vec = Vec::new(); + // The records as the reader walks them: it stops at the end record, a record shorter than its + // header, one that runs past the file and a SAVEDC past its 1,024 levels. + while let (Some(words), Some(function)) = (u32_at(at), u16_at(at.saturating_add(4))) { + let size = (words as usize).saturating_mul(2); + let Some(next) = at.checked_add(size) else { break }; + if function == 0 || size < 6 || next > bytes.len() { + break; + } + match function { + // SETMAPMODE, when the record holds its mode. + 0x0103 if size >= 8 => mode = u16_at(at + 6).unwrap_or(mode), + // SAVEDC. + 0x001E => { + if saved.len() >= SAVED_LEVELS { + break; + } + saved.push(mode); + } + // RESTOREDC: a negative count pops that many levels, a positive one goes back to that + // level. One that names no saved level changes nothing. + 0x0127 if size >= 8 => { + let n = u16_at(at + 6).map_or(0, |v| i32::from(i16::from_le_bytes(v.to_le_bytes()))); + let level = if n < 0 { saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) }; + if let Some(level) = level + && let Some(m) = saved.get(level).copied() + { + mode = m; + saved.truncate(level); + } + } + f if WMF_DRAWS.contains(&f) => { + if metric(mode) { + in_metric += 1; + } else { + in_other += 1; + } + } + _ => {} + } + at = next; + } + if in_metric == in_other { metric(mode) } else { in_metric > in_other } +} + /// Points a picture unit across and down. `craft_metafile` gives a placeable WMF with a box and an -/// EMF with a frame in points, and other files in their logical (WMF) or device (EMF) units. +/// EMF with a frame in points, and other files in their logical units (WMF; pixels in a metric map +/// mode) or device units (EMF). fn unit(bytes: &[u8], kind: Kind) -> (f64, f64) { let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); let i16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| i16::from_le_bytes([b[0], b[1]])); @@ -59,15 +130,20 @@ fn unit(bytes: &[u8], kind: Kind) -> (f64, f64) { let positive = |v: Option| v.filter(|v| *v > 0).map(f64::from); match kind { Kind::Wmf => { - if bytes.get(..4) != Some(&PLACEABLE_KEY.to_le_bytes()[..]) { - return (WMF_UNIT, WMF_UNIT); - } - if let (Some(l), Some(t), Some(r), Some(b)) = (i16_at(6), i16_at(8), i16_at(10), i16_at(12)) + let placeable = bytes.get(..4) == Some(&PLACEABLE_KEY.to_le_bytes()[..]); + if placeable + && let (Some(l), Some(t), Some(r), Some(b)) = (i16_at(6), i16_at(8), i16_at(10), i16_at(12)) && r > l && b > t { return (1.0, 1.0); } + if wmf_metric(bytes, if placeable { 22 } else { 0 }) { + return (PT_PER_PX, PT_PER_PX); + } + if !placeable { + return (WMF_UNIT, WMF_UNIT); + } let k = 72.0 / u16_at(14).filter(|v| *v > 0).map_or(1440.0, f64::from); (k, k) } @@ -85,7 +161,7 @@ fn unit(bytes: &[u8], kind: Kind) -> (f64, f64) { match px_mm { Some(_) if frame => (1.0, 1.0), Some((x, y)) if ok(x) && ok(y) => (PT_PER_MM / x, PT_PER_MM / y), - _ => (0.75, 0.75), + _ => (PT_PER_PX, PT_PER_PX), } } } @@ -330,10 +406,14 @@ impl Mapper { PathEl::MoveTo(p) => Some(*p), _ => None, }); - // A translucent stroke stays apart, so where strokes overlap each one shows. + // A translucent stroke stays apart, so where strokes overlap each one shows. So + // does a dashed one, whose dashes start at its own start, and one whose caps + // or joins aren't round: a join where two strokes meet is not their two caps. if let Some(p) = &mut self.pending && p.pen == pen && pen.color[3] == u8::MAX + && pen.line.dash.is_empty() + && matches!((&pen.line.cap, &pen.line.join), (Cap::Round, Join::Round)) && start.is_some() && p.path.elements().last().and_then(|e| e.end_point()) == start { diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 55714c1f71..a193997c4b 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -349,6 +349,66 @@ fn wmf_without_a_placeable_header_is_read_at_1440_units_an_inch() { assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 72.0), 1e-6), "{:?}", p[0].geometric_bounds()); } +#[test] +fn wmf_without_a_placeable_box_in_a_metric_map_mode_is_read_at_96_pixels_an_inch() { + // MM_TWIPS (y up) and a window of 1440 twips square: one inch. The rectangle is the whole window. + let records = vec![(0x0103, le16(&[6])), (0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), (0x041B, le16(&[1440, 1440, 0, 0]))]; + let boxed = hand_wmf(1440, 1440, &records); + let plain = boxed[22..].to_vec(); + // The box runs from right to left, so it gives no size. + let mut inverted = boxed; + inverted[6..8].copy_from_slice(&1440i16.to_le_bytes()); + inverted[10..12].copy_from_slice(&0i16.to_le_bytes()); + for bytes in [plain, inverted] { + let back = import(&bytes).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 72.0, 72.0), 1e-4), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 72.0), 1e-4), "{:?}", p[0].geometric_bounds()); + } +} + +#[test] +fn wmf_whose_metric_map_mode_is_restored_away_is_read_at_1440_units_an_inch() { + // SAVEDC, MM_TWIPS, then RESTOREDC back to MM_TEXT, by a count of levels and by a level; the + // drawing is then the one in `wmf_without_a_placeable_header_is_read_at_1440_units_an_inch`. + for restore in [-1i16, 1] { + let records = vec![ + (0x001E, Vec::new()), + (0x0103, le16(&[6])), + (0x0127, le16(&[restore])), + (0x020B, le16(&[0, 0])), + (0x020C, le16(&[1440, 2880])), + (0x041B, le16(&[1440, 1440, 0, 0])), + ]; + let plain = hand_wmf(2880, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 144.0, 72.0), 1e-6), "{restore}: {:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 72.0), 1e-6), "{restore}: {:?}", p[0].geometric_bounds()); + } +} + +#[test] +fn wmf_that_draws_in_a_metric_map_mode_and_restores_is_read_at_96_pixels_an_inch() { + // SAVEDC, MM_TWIPS, a one-inch rectangle, then RESTOREDC back to MM_TEXT with nothing more drawn. + let records = vec![ + (0x001E, Vec::new()), + (0x0103, le16(&[6])), + (0x020B, le16(&[0, 0])), + (0x020C, le16(&[1440, 1440])), + (0x041B, le16(&[1440, 1440, 0, 0])), + (0x0127, le16(&[-1])), + ]; + let plain = hand_wmf(1440, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "{b:?}"); +} + #[test] fn damaged_files_fail_or_import_partly() { let good = emf(&sample()).bytes; @@ -590,6 +650,33 @@ fn translucent_strokes_drawn_one_after_another_stay_apart() { assert_eq!(lines(0xFFFF_0000).len(), 1); } +#[test] +fn strokes_join_only_with_round_caps_round_joins_and_no_dashes() { + // CREATEPENINDIRECT: a 20-unit black pen of `style`, selected; then two lines one after the + // other that meet at a right angle. + let lines = |style: i16| { + let records = vec![ + (0x02FA, [le16(&[style, 20, 0]), 0u32.to_le_bytes().to_vec()].concat()), + (0x012D, le16(&[0])), + (0x0214, le16(&[100, 100])), + (0x0213, le16(&[100, 700])), + (0x0213, le16(&[700, 700])), + ]; + paths(&import(&hand_wmf(1440, 1440, &records)).unwrap().document) + }; + // Round caps and round joins, a solid line: one path, as a round join is the two round caps. + assert_eq!(lines(0).len(), 1); + // Flat caps and miter joins, flat caps alone and miter joins alone: each line keeps its caps. + for style in [0x2200, 0x0200, 0x2000] { + let p = lines(style); + assert_eq!(p.len(), 2, "style {style:#x}: {p:?}"); + } + // PS_DASH: each line's dashes start at its own start. + let p = lines(1); + assert_eq!(p.len(), 2, "{p:?}"); + assert!(p.iter().all(|n| n.appearance.stroke().is_some_and(|st| st.dash.is_some())), "{p:?}"); +} + #[test] fn pattern_brushes_fill_with_their_average_color() { // CREATEDIBPATTERNBRUSHPT: a 2 × 2 tile of 24-bit pixels, two red and two blue (bottom-up rows From 22ec066bb737affdd2379ef24bc342529b0bb815 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:03:08 -0800 Subject: [PATCH 44/82] Blend: a turned hue gradient that reflects keeps its swatch links A hue gradient that reflects unrolls through its curve, whose stops have no swatch links, so a gradient along a closed key started elsewhere lost its links. The links now come from the gradient's own stops unrolled as a Classic gradient's, in order, so stops at one place and the stops where two runs meet keep their own links. --- crates/doc/src/blend.rs | 85 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 69016363af..dee5138751 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -668,8 +668,12 @@ fn reoriented(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wr /// One of `g`'s own stops keeps its swatch link (each stop of a step its own), and a stop between /// two of them the link [`link_at`] reads there. fn gradient_from(g: &Gradient, s: f32) -> Gradient { + // A hue gradient that reflects unrolls through its curve, whose stops have no links: the links + // come from its own stops unrolled as a Classic gradient's, which keep them. + let hue_reflect = g.is_spread() && g.spread == GradientSpread::Reflect && g.method.is_hue(); + let classic = hue_reflect.then(|| Gradient { method: GradientMethod::Classic, ..g.clone() }); let g = g.unrolled(); - let mut own = g.stops.clone(); + let mut own = classic.as_ref().map_or_else(|| g.stops.clone(), |c| c.unrolled().stops.clone()); own.sort_by(|a, b| a.offset.total_cmp(&b.offset)); let mut stops = Vec::new(); // The part from `s` to the end runs first, then the part up to `s`. @@ -3011,6 +3015,85 @@ mod tests { p => panic!("a gradient: {p:?}"), }; assert_eq!(ends, (Some(Some("Paper".into())), Some(Some("Ink".into()))), "{:?}", s.paint); + // A hue gradient that reflects keeps its links on the repeated stops. + let hue = keyed(square(100.0), &|s| { + let (mut red, mut blue) = + (GradientStop::new(0.0, Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }), GradientStop::new(1.0, Color::Rgb { r: 0.0, g: 0.0, b: 1.0 })); + (red.swatch, blue.swatch) = (Some("Ink".into()), Some("Paper".into())); + let g = Gradient { + method: GradientMethod::HueClockwise, + spread: GradientSpread::Reflect, + repeats: 2, + ..Gradient::new(vectorcraft_color::GradientKind::Linear, vec![red, blue]) + }; + s.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + s.gradient_mode = StrokeGradientMode::Along; + }); + let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &hue, (Some(0), Some(2))).b); + let linked = |name: &str| match &s.paint { + Paint::Gradient(g) => g.gradient.stops.iter().filter(|st| st.swatch.as_deref() == Some(name)).map(|st| st.offset).collect::>(), + p => panic!("a gradient: {p:?}"), + }; + assert!(linked("Paper").contains(&0.0) && linked("Paper").contains(&1.0), "{:?}", s.paint); + assert!(linked("Ink").contains(&0.5), "{:?}", s.paint); + // Two stops of one color at one place keep their own links, in reverse in a mirrored run. + let hue_step = keyed(square(100.0), &|s| { + let green = Color::Rgb { r: 0.0, g: 1.0, b: 0.0 }; + let (mut before, mut after) = (GradientStop::new(0.5, green), GradientStop::new(0.5, green)); + (before.swatch, after.swatch) = (Some("Ink".into()), Some("Paper".into())); + let stops = vec![ + GradientStop::new(0.0, Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }), + before, + after, + GradientStop::new(1.0, Color::Rgb { r: 0.0, g: 0.0, b: 1.0 }), + ]; + let g = Gradient { + method: GradientMethod::HueClockwise, + spread: GradientSpread::Reflect, + repeats: 2, + ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) + }; + s.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + s.gradient_mode = StrokeGradientMode::Along; + }); + let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &hue_step, (Some(0), Some(2))).b); + // The links at `u` in order, a link repeated by the next stop listed once. + let links_at = |u: f32| match &s.paint { + Paint::Gradient(g) => { + let mut links = + g.gradient.stops.iter().filter(|st| (st.offset - u).abs() < 1e-4).filter_map(|st| st.swatch.clone()).collect::>(); + links.dedup(); + links + } + p => panic!("a gradient: {p:?}"), + }; + assert_eq!(links_at(0.25), vec!["Paper".to_string(), "Ink".to_string()], "{:?}", s.paint); + assert_eq!(links_at(0.75), vec!["Ink".to_string(), "Paper".to_string()], "{:?}", s.paint); + // Where two runs meet, each run's stops keep their own links. + let hue_end = keyed(square(100.0), &|s| { + let green = Color::Rgb { r: 0.0, g: 1.0, b: 0.0 }; + let (mut a, mut b) = (GradientStop::new(1.0, green), GradientStop::new(1.0, green)); + (a.swatch, b.swatch) = (Some("Ink".into()), Some("Paper".into())); + let g = Gradient { + method: GradientMethod::HueClockwise, + spread: GradientSpread::Reflect, + repeats: 2, + ..Gradient::new(vectorcraft_color::GradientKind::Linear, vec![GradientStop::new(0.0, Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }), a, b]) + }; + s.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + s.gradient_mode = StrokeGradientMode::Along; + }); + let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &hue_end, (Some(0), Some(1))).b); + let links_at = |u: f32| match &s.paint { + Paint::Gradient(g) => { + let mut links = + g.gradient.stops.iter().filter(|st| (st.offset - u).abs() < 1e-4).filter_map(|st| st.swatch.clone()).collect::>(); + links.dedup(); + links + } + p => panic!("a gradient: {p:?}"), + }; + assert_eq!(links_at(0.25), vec!["Ink".to_string(), "Paper".to_string(), "Ink".to_string()], "{:?}", s.paint); // Blended with a gradient of other stops, the steps keep that step. let three = keyed(square(0.0), &|s| { let mut g = Gradient::default(); From d2e36f689b45f36589fc697fa05ac9e9b08e3f9c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:10:37 -0800 Subject: [PATCH 45/82] Docs: PDF/X-1a writes tagged gray images without their profile The progress-doc line for gray and RGB image profiles in PDF export now states that PDF/X-1a, which allows no ICC-based color spaces, writes a gray image with a gray profile in DeviceGray without the profile, and converts RGB images to CMYK as before. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 37780eb391..129079e0c0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1831,7 +1831,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - PDF export writes a CMYK image that keeps its numbers in an `ICCBased` color space holding, byte for byte, the CMYK profile the image carries (a CMYK TIFF's tag 34675, or a CMYK JPEG's `ICC_PROFILE` APP2 segments) when `output.profiles` is `all` or `taggedSource`, and in PDF/A-2b, PDF/X-3 and PDF/X-4 unless it is `destination` ([the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html)). This covers a CMYK JPEG embedded as it is, one the Compression settings resample or compress again, a TIFF written again, and an image written with its mask as its soft mask. With `none`, and in PDF/X-1a, the image is DeviceCMYK and a JPEG's own profile is left out, so PDF/X-1a export writes a CMYK JPEG that carries a profile. Pixels the Output settings convert are never tagged with the profile of their old numbers. A profile tags an image only when its size, signature, class, CMYK color space and tag table are well formed and it is at most 16 MiB (VectorCraft's own limit). Transparency in a tagged CMYK document blends in the document's CMYK profile; when the file holds no space of that profile it blends in DeviceCMYK, or keeps its RGB space in PDF/A, and never in an image's own profile (`crates/pdf/src/tests/cmykimages.rs`, `export::writer::tests`). - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, and a profile whose color space differs from the samples', are written without it. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`). + - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, and a profile whose color space differs from the samples', are written without it. PDF/X-1a allows no ICC-based color spaces, so there a gray image with a gray profile, kept as it is, resampled or compressed with JPEG, is written in DeviceGray without the profile, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). - SVG import reads the files that a linked SVG file's own `` elements link to, relative paths from that file's folder (), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). From 3be00f4bd35d5058a4e0d7f8dc2916963459252e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:14:38 -0800 Subject: [PATCH 46/82] Blend: a gradient steps where the other does only at a step of its own When two gradients with different stop counts are blended, the second stop at the other gradient's step took the last stop within the step tolerance, so two close but distinct stops became a hard step. It now takes the last stop of a step of its own there, and otherwise paints what the gradient paints at that place. --- crates/doc/src/blend.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index dee5138751..c269294c2f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -112,8 +112,8 @@ fn link_in(stops: &[GradientStop], t: f32) -> (Option, f32, Option<[f32; const STEP: f32 = 1e-4; /// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred). An -/// offset given twice is a step: the second stop paints what `g` steps to there (its last stop -/// within [`STEP`] of it), or what it paints there when it does not step. +/// offset given twice is a step: the second stop paints what `g` steps to there (the last of its +/// stops at one offset within [`STEP`] of it), or what it paints there when it does not step. fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient { let mut out = g.clone(); out.stops = offsets @@ -121,8 +121,8 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient { .enumerate() .map(|(i, &o)| { let after = i.checked_sub(1).and_then(|j| offsets.get(j)) == Some(&o); - let mut near = g.stops.iter().filter(|s| (s.offset - o).abs() < STEP); - let stepped = if after && near.next().is_some() { near.next_back() } else { None }; + let stepped = + if after { g.stops.windows(2).rfind(|w| w[0].offset == w[1].offset && (w[0].offset - o).abs() < STEP).map(|w| &w[1]) } else { None }; match stepped { Some(s) => { GradientStop { opacity: s.opacity, swatch: s.swatch.clone(), tint: s.tint, plates: s.plates, ..GradientStop::new(o, s.color) } @@ -3138,6 +3138,19 @@ mod tests { assert_eq!(at(&a), vec![(0.0, Color::WHITE), (0.5, Color::BLACK), (0.5, Color::WHITE), (0.50015, red), (1.0, Color::BLACK)]); assert_eq!(b.stops.len(), 5); assert_eq!(b.stops.get(1).map(|s| s.color), b.stops.get(2).map(|s| s.color), "a gradient without the step paints one color there"); + // A gradient with two stops close to the other's step, but no step of its own, does not step there. + let close = Gradient::new( + vectorcraft_color::GradientKind::Linear, + vec![ + GradientStop::new(0.0, Color::WHITE), + GradientStop::new(0.5, Color::BLACK), + GradientStop::new(0.50005, red), + GradientStop::new(1.0, Color::BLACK), + ], + ); + let (_, b) = common_stops(&stepped, &close).unwrap_or_else(|| panic!("one kind")); + assert_eq!(b.stops.get(1).map(|s| s.color), Some(Color::BLACK)); + assert_eq!(b.stops.get(2).map(|s| s.color), Some(Color::BLACK), "{:?}", b.stops); } #[test] From 2c5f3b9d20cddc5e75ac9427fe9cfa7736c38e0b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:33:00 -0800 Subject: [PATCH 47/82] SVG import: refuse an SVG whose use copies and styles expand past the limits usvg copies the element a shows, and every copy gets its own copy of the declarations its style attribute and matching style sheet rules give it. A chain of s that doubles at each level made usvg build those copies before the import's object limit was checked: a 101 kB file whose rectangle has a 100 kB style took 4.7 GB at 14 levels, and one 100 kB style sheet rule on 16,000 groups took 1.5 GB without any . A plain chain stopped at usvg's own 1,000,000-node limit with a parse error. The import now measures, before usvg reads the SVG, how many elements it expands to and how many bytes of style values those elements copy, each element measured once and each counted as one addition. References follow usvg's rules: first element of an id, href before xlink:href, and the self and back references usvg leaves out. An SVG past 999,999 elements (500,000 on the web) is refused as too large, a loop of references usvg would copy without end is refused too, and one past 64 MB of copied style values is refused with the new SvgError::StylesTooLarge. SVG images count toward the same 64 MB as the SVG showing them, an image past either limit refuses the whole import, and an image that doesn't decode does not count. --- crates/svg/src/import.rs | 10 + crates/svg/src/import/budget.rs | 462 ++++++++++++++++++++++++++ crates/svg/src/import/files.rs | 42 ++- crates/svg/src/lib.rs | 4 + crates/svg/tests/it/import_details.rs | 60 ++++ crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/target-app-parity.md | 1 + 29 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 crates/svg/src/import/budget.rs diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 087512bf90..b583f3dc03 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -43,6 +43,7 @@ pub(super) use prepass::Edits; use prepass::{Extras, labels, prepass}; pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE}; +mod budget; mod css; mod css_transform; mod depth; @@ -154,7 +155,13 @@ pub(crate) fn import_finding( let mut warnings = found.warnings; let text_xml = found.text_source.as_deref().map(parse).transpose()?; let (src, slots) = text::prepare(svg, &xml, text_xml.as_ref(), units.dpi, &mut warnings); + // Before usvg copies what ``s show and the style values of each copy: the text usvg reads. + let styles = match &src { + Cow::Borrowed(_) => budget::check(&xml, budget::MAX_COPIED_STYLES)?, + Cow::Owned(prepared) => budget::check(&parse(prepared)?, budget::MAX_COPIED_STYLES)?, + }; let files = found.files; + files.copied_styles(styles); let tree = { let opt = usvg::Options { dpi: units.dpi as f32, @@ -164,6 +171,9 @@ pub(crate) fn import_finding( }; usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))? }; + if let Some(err) = files.refused() { + return Err(err); + } let size = tree.size(); let k = units.k; let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k); diff --git a/crates/svg/src/import/budget.rs b/crates/svg/src/import/budget.rs new file mode 100644 index 0000000000..c2ff01a02f --- /dev/null +++ b/crates/svg/src/import/budget.rs @@ -0,0 +1,462 @@ +//! What usvg makes of an SVG, measured in the XML before usvg runs: how many elements it makes, +//! and how many bytes of style values it copies into them. +//! +//! usvg makes an element of each SVG element, and of each element a `` shows a copy of, by +//! copying what the `` refers to. Each copy gets its own copy of every declaration the style +//! sheets and the `style` attribute give the element, so a long style on an element that ``s +//! copy, again and again in a chain, or that a style sheet rule gives to many elements, takes more +//! memory than the file holds many times over. usvg stops past 1,000,000 elements, but only after +//! it has made them and copied their styles. An SVG past [`MAX_SVG_ELEMENTS`] elements or +//! [`MAX_COPIED_STYLES`] bytes of copied styles is refused here instead. +//! +//! The copies are counted, never made: each element's count is worked out once and added up, so a +//! `` of an element costs one addition, however many elements its copy holds. References +//! follow usvg 0.48.1 (`parser/svgtree/parse.rs`): an id names the first element that has it, an +//! unprefixed `href` wins over `xlink:href`, and a `` of itself, of the `` its own copy +//! starts from, or of an element holding a `` of the element or of the ``, is left out. +//! Any other loop of references makes usvg copy without end, so an SVG with one is refused. usvg +//! looks for those ``s inside an element only in the SVG namespace: in an SVG without one, a +//! `` that leads back to the `` its copy starts from is refused here too, though usvg +//! would leave it out. +//! +//! The style sheets' rules are matched against each element once, as usvg matches them against +//! each copy. + +use std::collections::{HashMap, HashSet}; + +use usvg::roxmltree; + +use super::{MAX_NODES, SVG_NS, XNode}; +use crate::SvgError; + +/// Most elements usvg may make of an SVG: [`MAX_NODES`], and below the 1,000,000 elements past +/// which usvg stops (its count includes a root of its own). +pub(crate) const MAX_SVG_ELEMENTS: usize = if MAX_NODES < 999_999 { MAX_NODES } else { 999_999 }; + +/// Most bytes of style values usvg may copy into the elements of an SVG and the SVG images it +/// shows, all together: VectorCraft's own limit. +pub(crate) const MAX_COPIED_STYLES: u64 = 64 << 20; + +/// What usvg keeps for each value it copies besides its bytes (the count and length of a shared +/// string). +const COPY: u64 = 16; + +/// The longhands a `font` shorthand resets before setting the ones it gives, and the most bytes of +/// each reset value (`normal`). +const FONT_RESETS: u64 = 12; +const RESET_VALUE: u64 = 6; + +/// The longhands a `font` shorthand sets from its own text. +const FONT_PARTS: u64 = 6; + +const XLINK_NS: &str = "http://www.w3.org/1999/xlink"; + +/// The elements usvg reads (`EId` in usvg's `parser/svgtree/names.rs`); it skips the others with +/// everything inside them. +const ELEMENTS: [&str; 53] = [ + "a", + "circle", + "clipPath", + "defs", + "ellipse", + "feBlend", + "feColorMatrix", + "feComponentTransfer", + "feComposite", + "feConvolveMatrix", + "feDiffuseLighting", + "feDisplacementMap", + "feDistantLight", + "feDropShadow", + "feFlood", + "feFuncA", + "feFuncB", + "feFuncG", + "feFuncR", + "feGaussianBlur", + "feImage", + "feMerge", + "feMergeNode", + "feMorphology", + "feOffset", + "fePointLight", + "feSpecularLighting", + "feSpotLight", + "feTile", + "feTurbulence", + "filter", + "g", + "image", + "line", + "linearGradient", + "marker", + "mask", + "path", + "pattern", + "polygon", + "polyline", + "radialGradient", + "rect", + "stop", + "style", + "svg", + "switch", + "symbol", + "text", + "textPath", + "tref", + "tspan", + "use", +]; + +/// The name of element `n` when usvg reads it: in the SVG namespace or none, and one of +/// [`ELEMENTS`]. +fn svg_name<'a>(n: XNode<'a, '_>) -> Option<&'a str> { + let name = n.tag_name(); + (n.is_element() && matches!(name.namespace(), None | Some(SVG_NS)) && ELEMENTS.contains(&name.name())).then(|| name.name()) +} + +/// An element as the style sheets' selectors see it, as usvg matches them. +struct Element<'a, 'i>(XNode<'a, 'i>); + +impl simplecss::Element for Element<'_, '_> { + fn parent_element(&self) -> Option { + self.0.parent_element().map(Self) + } + + fn prev_sibling_element(&self) -> Option { + self.0.prev_sibling_element().map(Self) + } + + fn has_local_name(&self, name: &str) -> bool { + self.0.tag_name().name() == name + } + + fn attribute_matches(&self, name: &str, operator: simplecss::AttributeOperator<'_>) -> bool { + self.0.attribute(name).is_some_and(|value| operator.matches(value)) + } + + fn pseudo_class_matches(&self, class: simplecss::PseudoClass<'_>) -> bool { + matches!(class, simplecss::PseudoClass::FirstChild) && self.0.prev_sibling_element().is_none() + } +} + +/// The bytes usvg copies for one declaration: three markers for `marker`, the resets and longhands +/// for `font`, its value for any other. Counted for every declaration, the ones usvg then drops (a +/// property it doesn't read, a value an earlier one overrides) too. +fn copied(declaration: &simplecss::Declaration) -> u64 { + let len = declaration.value.len() as u64; + match declaration.name { + "marker" => 3 * (COPY + len), + "font" => (FONT_RESETS + FONT_PARTS) * COPY + FONT_RESETS * RESET_VALUE + len, + _ => COPY + len, + } +} + +/// What usvg makes of one element: elements (text nodes included) and bytes of copied styles. +#[derive(Clone, Copy, Default)] +struct Cost { + elements: u64, + bytes: u64, +} + +impl Cost { + fn add(self, other: Cost) -> Cost { + Cost { elements: self.elements.saturating_add(other.elements), bytes: self.bytes.saturating_add(other.bytes) } + } +} + +/// What a `` shows: nothing, the element it refers to with all it holds, or only that +/// element (a `` whose own reference leads back to the `` showing it). +enum Shows<'a, 'i> { + Nothing, + All(XNode<'a, 'i>), + Bare(XNode<'a, 'i>), +} + +struct Measure<'a, 'i> { + /// The first element of each id. + ids: HashMap<&'a str, XNode<'a, 'i>>, + sheet: simplecss::StyleSheet<'a>, + /// The elements that hold a `` of themselves. + holds_use_of_itself: HashSet, + /// The ``s of each element, by the element. + used_by: HashMap>>, + /// The bytes of text in each element a `` shows. + tref_text: HashMap, +} + +impl<'a, 'i> Measure<'a, 'i> { + fn new(xml: &'a roxmltree::Document<'i>) -> Self { + let mut ids = HashMap::new(); + for n in xml.descendants() { + if let Some(id) = n.attribute("id") { + ids.entry(id).or_insert(n); + } + } + // The style sheets usvg reads: `{groups}"))); + assert_eq!(bytes, 4 * 19 + 20); + // A style sheet of another type is not read; the style element itself makes nothing. + assert_eq!(cost(&svg(&format!("{groups}"))), (5, 0)); + // `marker` sets three properties; `font` resets twelve and sets six from its text. + let (_, marker) = cost(&svg("")); + let (_, font) = cost(&svg("")); + assert_eq!((marker, font), (3 * (16 + 7), 18 * 16 + 12 * 6 + 6)); + } + + #[test] + fn references_usvg_leaves_out_copy_nothing() { + // A use of itself, of a group holding it, of what isn't there or isn't SVG. + let (n, _) = cost(&svg("")); + assert_eq!(n, 1 + 1 + 3 + 1 + 1); + // Two uses of each other: each shows the other alone. + assert_eq!(cost(&svg("")).0, 1 + 2 + 2); + // A group holding a use of the use that shows it: that use shows nothing, and the one the + // group holds shows it alone. + let back = svg(""); + assert_eq!(cost(&back).0, 1 + 3 + 1); + // The unprefixed href wins, and an id names its first element. + let first = svg(""); + assert_eq!(cost(&first).0, 1 + 3 + 1 + 1 + 2 + 4); + } + + #[test] + fn a_loop_usvg_copies_without_end_is_refused_as_usvg_refuses_it() { + let looped = svg(""); + assert_eq!(past(&looped, u64::MAX, u64::MAX), Some(Past::Elements)); + assert!(usvg::Tree::from_str(&looped, &usvg::Options::default()).is_err()); + // Without the loop, usvg reads it. + let open = svg(""); + assert!(past(&open, u64::MAX, u64::MAX).is_none() && usvg::Tree::from_str(&open, &usvg::Options::default()).is_ok()); + } + + #[test] + fn text_counts_its_runs_and_what_trefs_show() { + let (n, bytes) = cost(&svg("abcdxyz")); + // svg, g (its text is not counted outside a text), text, its runs "xy" and "z", tspan, tref and its text. + assert_eq!(n, 1 + 1 + 1 + 2 + 1 + 1 + 1); + assert_eq!(bytes, (16 + 2) + (16 + 1) + (16 + 4)); + } +} diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index d2b5504f9d..429c39c70f 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -21,7 +21,7 @@ use usvg::{ImageHrefResolver, ImageKind, roxmltree}; use vectorcraft_doc::LinkInfo; use vectorcraft_doc::links::missing_placeholder; -use super::{Edits, depth, href}; +use super::{Edits, budget, depth, href}; use crate::{ImportOptions, MAX_ELEMENT_DEPTH, MAX_SVGZ, SvgError}; /// Most nested SVG images an imported SVG may decode (#3564). @@ -74,6 +74,12 @@ pub(in crate::import) struct Files { /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got /// (kept here too, so the address stays its own). cmyk: Mutex>, + /// The bytes of style values usvg copies into the elements of the SVG and of the SVG images + /// read so far ([`budget`]). + styles: AtomicU64, + /// Why an SVG image refused the whole SVG: what usvg would make of it is past [`budget`]'s + /// limits. The first one. + refused: Mutex>, } /// Encoded image bytes, shared. @@ -479,6 +485,17 @@ impl Files { notes } + /// The SVG holding the images copies `bytes` of style values ([`budget::check`]): its images + /// copy at most what is left. + pub(in crate::import) fn copied_styles(&self, bytes: u64) { + self.styles.store(bytes, Ordering::Relaxed); + } + + /// The error of the first SVG image past [`budget`]'s limits, which refuses the SVG holding it. + pub(in crate::import) fn refused(&self) -> Option { + self.refused.lock().ok().and_then(|r| r.clone()) + } + fn note_text(&self, svg: &str) { if svg.contains(">, opts: &usvg::Options) -> Option { + // The SVG holding it is refused already: no more images are read. + if self.refused().is_some() { + return None; + } // Measured as the SVG holding it is (`import`): the text before the XML reader. let used = self.depth.load(Ordering::Relaxed); let left = MAX_ELEMENT_DEPTH.saturating_sub(used); @@ -569,6 +590,19 @@ impl Files { let Some(height) = depth::height(&xml, left) else { return leave_out(&self.too_deep, true) }; // usvg's parser would overflow on it, as on the SVG holding it (`import`). super::convolve_matrices_ok(&xml).ok()?; + // What usvg would make of it, measured as the SVG holding it is (`import`), its copied + // styles counted with theirs while usvg reads it, and kept only when it decodes: past the + // limits, the whole SVG is refused. + let spent = self.styles.load(Ordering::Relaxed); + match budget::check(&xml, budget::MAX_COPIED_STYLES.saturating_sub(spent)) { + Ok(bytes) => self.styles.store(spent.saturating_add(bytes), Ordering::Relaxed), + Err(e) => { + if let Ok(mut refused) = self.refused.lock() { + refused.get_or_insert(e); + } + return None; + } + } self.depth.store(used + height, Ordering::Relaxed); let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some(); // usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver. @@ -592,7 +626,11 @@ impl Files { w.pop(); } self.depth.store(used, Ordering::Relaxed); - let tree = tree.ok()?; + let Ok(tree) = tree else { + // Left out: neither it nor the images inside it count. + self.styles.store(spent, Ordering::Relaxed); + return None; + }; self.note_text(text); Some(ImageKind::SVG(tree)) } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index b035e6cd10..daf3288920 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -366,6 +366,10 @@ pub enum SvgError { /// nested-image expansion bombs, #3564). #[error("the SVG expands to more than {0} nested SVG images")] TooManyImages(usize), + /// The style values the reader would copy into the elements the SVG expands to, `` + /// copies and SVG images included, add up to more than this many megabytes. + #[error("the SVG's styles expand to more than {0} MB")] + StylesTooLarge(usize), } /// Export a document as an SVG string (linked images are not returned: see [`export_full`]). diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 69e5cafa21..6276f5ee77 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -851,6 +851,66 @@ fn nested_image_within_budget_imports_and_use_fan_out_is_refused() { assert!(matches!(err, SvgError::TooLarge(15)), "{err:?}"); } +/// Groups `g0` to `g{levels}`: `g0` a rectangle styled `style`, each other two ``s of the +/// one before; one `` of the last shown. +fn use_chain(levels: usize, style: &str) -> String { + let links: String = (1..=levels).map(|i| format!(r##""##, i - 1)).collect(); + svg(&format!(r##"{links}"##)) +} + +/// A style value of 100,000 bytes. +fn long_style() -> String { + format!("font-family:{}", "a".repeat(100_000)) +} + +/// A long style that a chain of ``s copies, or that a style sheet rule gives to many +/// elements, is refused before usvg copies it; short styles import, a rule that matches nothing +/// costing nothing. +#[test] +fn styles_that_expand_past_the_limit_are_refused() { + let err = import_with(&use_chain(10, &long_style()), &ImportOptions::default()).unwrap_err(); + assert!(matches!(err, SvgError::StylesTooLarge(64)), "{err:?}"); + assert_eq!(err.to_string(), "the SVG's styles expand to more than 64 MB"); + // 1,000 groups that one rule gives the long style, without any ``. + let sheet = svg(&format!("{}", long_style(), "".repeat(1000))); + let err = import_with(&sheet, &ImportOptions::default()).unwrap_err(); + assert!(matches!(err, SvgError::StylesTooLarge(64)), "{err:?}"); + + // The chain with a short style shows its 1,024 rectangles. + let (d, _) = import_with(&use_chain(10, "fill:red"), &ImportOptions::default()).unwrap(); + assert_eq!(art(&d).iter().filter(|n| n.children().is_none()).count(), 1024); + // 5,000 rectangles of a class with short rules, beside a long rule that matches none of them. + let rules = format!(".a{{fill:#123456;stroke:#654321;stroke-width:2;opacity:.5}} .b{{{}}}", long_style()); + let classed = svg(&format!("{}", r#""#.repeat(5000))); + let (d, _) = import_with(&classed, &ImportOptions::default()).unwrap(); + assert_eq!(art(&d).len(), 5000); +} + +/// A chain of ``s that makes more elements than usvg reads is refused before usvg makes them. +#[test] +fn a_use_chain_past_the_elements_usvg_reads_is_refused() { + let err = import_with(&use_chain(20, ""), &ImportOptions::default()).unwrap_err(); + assert!(matches!(err, SvgError::TooLarge(n) if n == vectorcraft_svg::MAX_NODES.min(999_999)), "{err:?}"); +} + +/// An SVG image whose styles expand past the limit refuses the SVG showing it, and the SVG images +/// share the limit with the SVG showing them. +#[test] +fn svg_images_whose_styles_expand_past_the_limit_refuse_the_svg() { + let err = import_with(&svg(&image_of("image/svg+xml", &use_chain(10, &long_style()))), &ImportOptions::default()).unwrap_err(); + assert!(matches!(err, SvgError::StylesTooLarge(64)), "{err:?}"); + // Each image copies its long style 383 times (about 38 MB): one imports, two together don't. + let image = image_of("image/svg+xml", &use_chain(7, &long_style())); + let (d, _) = import_with(&svg(&image), &ImportOptions::default()).unwrap(); + assert!(!art(&d).is_empty()); + let err = import_with(&svg(&image.repeat(2)), &ImportOptions::default()).unwrap_err(); + assert!(matches!(err, SvgError::StylesTooLarge(64)), "{err:?}"); + // An image that doesn't decode (no size) is left out, and what it would copy doesn't count. + let sizeless = use_chain(7, &long_style()).replacen(r#"width="200" height="120""#, r#"width="0" height="0""#, 1); + let (d, _) = import_with(&svg(&format!("{}{image}", image_of("image/svg+xml", &sizeless).repeat(2))), &ImportOptions::default()).unwrap(); + assert!(!art(&d).is_empty()); +} + /// Nested SVG images exceeding `MAX_NESTED_SVG_IMAGES` are left out with a warning note (#3564). #[test] fn nested_svg_images_past_count_cap_are_left_out() { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..3d07f33af2 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6332,6 +6332,7 @@ digits Malayalam Malaiàlam @msg the spine has no segments l’espina no té segments @msg the stroke has no weight el traç no té gruix @msg the SVG's elements are nested more than {0} deep els elements de l’SVG estan imbricats a més de {0} nivells de profunditat +@msg the SVG's styles expand to more than {0} MB els estils de l’SVG s’expandeixen a més de {0} MB @msg the target layer is locked or hidden la capa de destinació està blocada o amagada @msg the thumbnail image is malformed la imatge de la miniatura està mal formada @msg the top object must be a path, compound path or text object to use as a clipping mask l’objecte superior ha de ser un traçat, un traçat compost o un objecte de text per fer-lo servir com a màscara de retall diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..993ad41add 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -9008,6 +9008,7 @@ breakText Glyphs Glyfy @msg the RTF file is too large to place as type (at most {_1} MB) soubor RTF je příliš velký na umístění jako text (nejvýše {_1} MB) @msg the SVG expands to more than {0} nested SVG images SVG se rozvine na více než {0} vnořených obrázků SVG @msg the SVG expands to more than {0} objects SVG se rozvine na více než {0} objektů +@msg the SVG's styles expand to more than {0} MB styly SVG se rozvinou na více než {0} MB @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes soubor SVG je větší než {MAX_IMPORT_BYTES} bajtů @msg the SVG has no drawable objects SVG nemá žádné vykreslitelné objekty @msg the SVGZ file unpacks to more than {_1} MB soubor SVGZ se rozbalí na více než {_1} MB diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..dd34c7fcc9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -8576,6 +8576,7 @@ breakText Glyphs Glyphen @msg the RTF file is too large to place as type (at most {_1} MB) die RTF-Datei ist zu groß, um als Text platziert zu werden (höchstens {_1} MB) @msg the SVG expands to more than {0} nested SVG images das SVG wird zu mehr als {0} verschachtelten SVG-Bildern erweitert @msg the SVG expands to more than {0} objects das SVG wird zu mehr als {0} Objekten erweitert +@msg the SVG's styles expand to more than {0} MB die Stile des SVG werden auf mehr als {0} MB erweitert @msg the SVG has no drawable objects das SVG hat keine zeichenbaren Objekte @msg the SVGZ file unpacks to more than {_1} MB die SVGZ-Datei wird auf mehr als {_1} MB entpackt @msg the Word document has more than {MAX_PAGES} pages of properties, too many to place das Word-Dokument hat mehr als {MAX_PAGES} Seiten an Eigenschaften, zu viele zum Platzieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..4733e624b7 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4802,6 +4802,7 @@ digits Malayalam Μαλαγιάλαμ @msg result would exceed the canvas (coordinates out of range) το αποτέλεσμα θα υπερέβαινε τον καμβά (συντεταγμένες εκτός εύρους) @msg objects can't be nested more than 32 deep τα αντικείμενα δεν μπορούν να έχουν ένθεση μεγαλύτερη των 32 επιπέδων @msg the SVG's elements are nested more than {0} deep τα στοιχεία SVG έχουν ένθεση μεγαλύτερη των {0} επιπέδων +@msg the SVG's styles expand to more than {0} MB τα στυλ του SVG επεκτείνονται σε περισσότερα από {0} MB @msg scale must be a number from {_1} to {_2}, not {_3} το scale πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {_3} @msg scale must be a number from {_1} to {_2}, not {s} το scale πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {s} @msg select (or target) objects whose appearance isn't basic επιλέξτε (ή στοχεύστε) αντικείμενα των οποίων η εμφάνιση δεν είναι βασική diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..ed8917e87d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4846,6 +4846,7 @@ axis Both Ambos @msg result would exceed the canvas (coordinates out of range) el resultado excedería el lienzo (coordenadas fuera de rango) @msg objects can't be nested more than 32 deep los objetos no pueden anidarse a más de 32 niveles @msg the SVG's elements are nested more than {0} deep los elementos del SVG están anidados a más de {0} niveles +@msg the SVG's styles expand to more than {0} MB los estilos del SVG se expanden a más de {0} MB @msg scale must be a number from {_1} to {_2}, not {_3} scale debe ser un número de {_1} a {_2}, no {_3} @msg scale must be a number from {_1} to {_2}, not {s} scale debe ser un número de {_1} a {_2}, no {s} @msg select (or target) objects whose appearance isn't basic selecciona (o apunta a) objetos cuya apariencia no sea básica diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..95a87bfa07 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5061,6 +5061,7 @@ imageTrace Outline Ääriviiva @msg result would exceed the canvas (coordinates out of range) tulos ylittäisi kankaan (koordinaatit alueen ulkopuolella) @msg objects can't be nested more than 32 deep objekteja ei voi sisäkkäistää yli 32 tason syvyyteen @msg the SVG's elements are nested more than {0} deep elementit ovat SVG-tiedostossa sisäkkäin yli {0} tason syvyydessä +@msg the SVG's styles expand to more than {0} MB SVG-tiedoston tyylit laajenevat yli {0} megatavuksi @msg scale must be a number from {_1} to {_2}, not {_3} scale-arvon on oltava luku väliltä {_1}–{_2}, ei {_3} @msg scale must be a number from {_1} to {_2}, not {s} scale-arvon on oltava luku väliltä {_1}–{_2}, ei {s} @msg select (or target) objects whose appearance isn't basic valitse (tai kohdista) objektit, joiden ulkoasu ei ole perusulkoasu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..e2ac12fd59 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -8872,6 +8872,7 @@ breakText Glyphs Glyphes @msg the RTF file is too large to place as type (at most {_1} MB) le fichier RTF est trop volumineux pour être importé comme texte (au plus {_1} Mo) @msg the SVG expands to more than {0} nested SVG images le SVG se développe en plus de {0} images SVG imbriquées @msg the SVG expands to more than {0} objects le SVG se développe en plus de {0} objets +@msg the SVG's styles expand to more than {0} MB les styles du SVG se développent en plus de {0} Mo @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes le fichier SVG dépasse {MAX_IMPORT_BYTES} octets @msg the SVG has no drawable objects le SVG n’a aucun objet dessinable @msg the SVGZ file unpacks to more than {_1} MB le fichier SVGZ se décompresse en plus de {_1} Mo diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..afd8f1d754 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8280,6 +8280,7 @@ antiAlias Sharp शार्प @msg the subpath is closed or too short to close सबपैथ बंद है या बंद करने के लिए बहुत कम है @msg the SVG expands to more than {0} nested SVG images SVG {0} नेस्टेड SVG इमेज से अधिक का विस्तार करता है @msg the SVG expands to more than {0} objects SVG {0} ऑब्जेक्ट्स से अधिक का विस्तार करता है +@msg the SVG's styles expand to more than {0} MB SVG की शैलियाँ {0} MB से अधिक तक विस्तारित होती हैं @msg the SVG has no drawable objects SVG में कोई आकर्षक ऑब्जेक्ट नहीं है @msg the SVG's elements are nested more than {0} deep SVG के तत्वों को {0} से अधिक गहरा घोंसला दिया जाता है @msg the SVGZ file unpacks to more than {_1} MB SVGZ फ़ाइल ने {_1} MB से अधिक के लिए अनपैक किया diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a1f6100b2f..f29564ed97 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -8877,6 +8877,7 @@ breakText Glyphs Glifák @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's styles expand to more than {0} MB az SVG stílusai kibontva több mint {0} MB-ot foglalnak @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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6743704785..60ad4e99a6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -8624,6 +8624,7 @@ breakText Glyphs Glifi @msg the RTF file is too large to place as type (at most {_1} MB) il file RTF è troppo grande per essere inserito come testo (al massimo {_1} MB) @msg the SVG expands to more than {0} nested SVG images l'SVG si espande in più di {0} immagini SVG annidate @msg the SVG expands to more than {0} objects l'SVG si espande in più di {0} oggetti +@msg the SVG's styles expand to more than {0} MB gli stili dell'SVG si espandono a oltre {0} MB @msg the SVG has no drawable objects l'SVG non ha oggetti disegnabili @msg the SVGZ file unpacks to more than {_1} MB il file SVGZ si decomprime in più di {_1} MB @msg the Word document has more than {MAX_PAGES} pages of properties, too many to place il documento Word ha più di {MAX_PAGES} pagine di proprietà, troppe per essere inserito diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9cdb8fcbe..f00bb9c388 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7835,6 +7835,7 @@ axis Y Axis Y 軸 @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's styles expand to more than {0} MB SVG のスタイルを展開すると {0} MB を超えます @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} ページを超えており、多すぎて配置できません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..8a155a1f76 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5678,6 +5678,7 @@ imageTrace Outline კონტური @msg object has no weighted stroke ობიექტს ცვლადი სისქის კონტური არ აქვს @msg objects can't be nested more than 32 deep ობიექტების ჩადგმის სიღრმე 32-ს ვერ გადააჭარბებს @msg the SVG's elements are nested more than {0} deep SVG-ის ელემენტების ჩადგმის სიღრმე {0}-ს აჭარბებს +@msg the SVG's styles expand to more than {0} MB SVG-ის სტილები გაშლისას {0} MB-ს აჭარბებს @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode უცნობი control `{name}`: fillStroke, coloring, drawMode ან screenMode @plural agrees with {n} default shortcut|agrees with {n} default shortcuts ემთხვევა ნაგულისხმევ მალსახმობებს: {n}|ემთხვევა ნაგულისხმევ მალსახმობებს: {n} @plural {n} part of the file wasn't read|{n} parts of the file weren't read ფაილის წაუკითხავი ნაწილები: {n}|ფაილის წაუკითხავი ნაწილები: {n} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..27dde64326 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -8605,6 +8605,7 @@ breakText Glyphs 글리프 @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's styles expand to more than {0} MB SVG의 스타일을 확장하면 {0}MB를 넘습니다 @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes SVG 파일이 {MAX_IMPORT_BYTES}바이트보다 큽니다 @msg the SVG has no drawable objects SVG에 그릴 수 있는 개체가 없습니다 @msg the SVGZ file unpacks to more than {_1} MB SVGZ 파일의 압축을 풀면 {_1} MB를 넘습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..2598123649 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6957,6 +6957,7 @@ imageTrace Outline Contour @msg the RTF file is too large to place as type (at most {_1} MB) het RTF-bestand is te groot om als tekst te plaatsen (maximaal {_1} MB) @msg the SVG expands to more than {0} nested SVG images de SVG wordt uitgevouwen tot meer dan {0} geneste SVG-afbeeldingen @msg the SVG expands to more than {0} objects de SVG wordt uitgevouwen tot meer dan {0} objecten +@msg the SVG's styles expand to more than {0} MB de stijlen van de SVG worden uitgevouwen tot meer dan {0} MB @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes het SVG-bestand is groter dan {MAX_IMPORT_BYTES} bytes @msg the SVG has no drawable objects de SVG heeft geen tekenbare objecten @msg the SVGZ file unpacks to more than {_1} MB het SVGZ-bestand wordt uitgepakt tot meer dan {_1} MB diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..02f807a4b0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -8862,6 +8862,7 @@ breakText Glyphs Glify @msg the RTF file is too large to place as type (at most {_1} MB) plik RTF jest zbyt duży, aby umieścić go jako tekst (najwyżej {_1} MB) @msg the SVG expands to more than {0} nested SVG images SVG rozwija się do ponad {0} zagnieżdżonych obrazów SVG @msg the SVG expands to more than {0} objects SVG rozwija się do ponad {0} obiektów +@msg the SVG's styles expand to more than {0} MB style SVG rozwijają się do ponad {0} MB @msg the SVG has no drawable objects SVG nie zawiera obiektów, które można narysować @msg the SVGZ file unpacks to more than {_1} MB plik SVGZ po rozpakowaniu ma ponad {_1} MB @msg the Word document has more than {MAX_PAGES} pages of properties, too many to place dokument Word ma ponad {MAX_PAGES} stron właściwości, zbyt wiele, aby go umieścić diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..6143ab85e3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -9074,6 +9074,7 @@ swatch Yellow Green Verde-amarelado @msg the RTF file is too large to place as type (at most {_1} MB) o arquivo RTF é grande demais para ser colocado como texto (no máximo {_1} MB) @msg the SVG expands to more than {0} nested SVG images o SVG se expande em mais de {0} imagens SVG aninhadas @msg the SVG expands to more than {0} objects o SVG se expande em mais de {0} objetos +@msg the SVG's styles expand to more than {0} MB os estilos do SVG se expandem para mais de {0} MB @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes o arquivo SVG tem mais de {MAX_IMPORT_BYTES} bytes @msg the SVG has no drawable objects o SVG não tem objetos desenháveis @msg the SVGZ file unpacks to more than {_1} MB o arquivo SVGZ é descompactado em mais de {_1} MB diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..2eb060d198 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4883,6 +4883,7 @@ axis Both Оба @msg result would exceed the canvas (coordinates out of range) результат превысил бы холст (координаты вне диапазона) @msg objects can't be nested more than 32 deep объекты нельзя вкладывать глубже 32 уровней @msg the SVG's elements are nested more than {0} deep элементы SVG вложены глубже {0} уровней +@msg the SVG's styles expand to more than {0} MB после развёртывания стили SVG занимают более {0} МБ @msg scale must be a number from {_1} to {_2}, not {_3} scale должен быть числом от {_1} до {_2}, а не {_3} @msg scale must be a number from {_1} to {_2}, not {s} scale должен быть числом от {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic выделите (или укажите как объект) объекты, оформление которых не является основным diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..9d568f8984 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8650,6 +8650,7 @@ digits Hindi Hindi @msg the subpath has no ends to extend (it is closed or a single point) delbanan har inga ändar att förlänga (den är stängd eller en enkel punkt) @msg the SVG expands to more than {0} nested SVG images SVG-filen expanderar till fler än {0} kapslade SVG-bilder @msg the SVG expands to more than {0} objects SVG-filen expanderar till fler än {0} objekt +@msg the SVG's styles expand to more than {0} MB SVG-filens stilar expanderar till mer än {0} MB @msg the SVG has no drawable objects SVG-filen har inga ritbara objekt @msg the SVGZ file unpacks to more than {_1} MB SVGZ-filen packas upp till mer än {_1} MB @msg the symbols are nested too deeply to expand their instances symbolerna är kapslade för djupt för att deras instanser ska kunna expanderas diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..b39df911ec 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4616,6 +4616,7 @@ axis Both Her İkisi @msg result would exceed the canvas (coordinates out of range) sonuç tuvali aşacaktır (koordinatlar aralık dışındadır) @msg objects can't be nested more than 32 deep nesneler 32'den fazla derinliğe yerleştirilemez @msg the SVG's elements are nested more than {0} deep SVG'nin öğeleri {0} derinliğinden daha fazla yuvalanmıştır +@msg the SVG's styles expand to more than {0} MB SVG'nin stilleri genişletildiğinde {0} MB'tan fazla yer kaplıyor @msg scale must be a number from {_1} to {_2}, not {_3} scale {_1} ile {_2} arasında bir sayı olmalıdır, {_3} değil @msg scale must be a number from {_1} to {_2}, not {s} scale {_1} ile {_2} arasında bir sayı olmalıdır, {s} değil @msg select (or target) objects whose appearance isn't basic Görünümü temel olmayan nesneleri seçin (veya hedefleyin) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..dc94cc74b4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -8490,6 +8490,7 @@ breakText Glyphs Гліфи @msg the RTF file is too large to place as type (at most {_1} MB) файл RTF завеликий, щоб помістити його як текст (не більше {_1} МБ) @msg the SVG expands to more than {0} nested SVG images SVG розгортається у понад {0} вкладених зображень SVG @msg the SVG expands to more than {0} objects SVG розгортається у понад {0} об’єктів +@msg the SVG's styles expand to more than {0} MB стилі SVG розгортаються у понад {0} МБ @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes файл SVG більший за {MAX_IMPORT_BYTES} байтів @msg the SVG has no drawable objects SVG не містить об’єктів, які можна намалювати @msg the SVGZ file unpacks to more than {_1} MB файл SVGZ розпаковується у понад {_1} МБ diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..c5c38c0ddd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5257,6 +5257,7 @@ theme Appearance Giao diện @msg result would exceed the canvas (coordinates out of range) kết quả sẽ vượt quá vùng vẽ (tọa độ ngoài phạm vi) @msg objects can't be nested more than 32 deep không thể lồng các đối tượng sâu quá 32 cấp @msg the SVG's elements are nested more than {0} deep các phần tử SVG lồng nhau sâu quá {0} cấp +@msg the SVG's styles expand to more than {0} MB các kiểu của SVG khi mở rộng chiếm hơn {0} MB @msg Save for Web: the image is empty Lưu cho Web: ảnh trống @msg Save writes {_1}, not {_2}: use document.export for other formats Lưu ghi {_1}, không phải {_2}: dùng document.export cho các định dạng khác @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Lưu ghi {_1}, không phải {_2}: dùng file.saveAsTemplate để ghi định dạng này diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..276c64f1b4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -8848,6 +8848,7 @@ breakText Glyphs 字形 @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's styles expand to more than {0} MB SVG 的样式展开后超过 {0} MB @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} 页,过多而无法置入 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..f0fe72eda2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -8773,6 +8773,7 @@ breakText Glyphs 字符 @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 展開後超過 {0} 個巢狀 SVG 影像 @msg the SVG expands to more than {0} objects SVG 展開後超過 {0} 個物件 +@msg the SVG's styles expand to more than {0} MB SVG 的樣式展開後超過 {0} MB @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} 頁,數量過多而無法置入 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8f5043993a..8b6136af58 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1833,6 +1833,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). + - SVG import measures what an SVG expands to before the SVG reader builds it: every copy a `` shows (), chains of ``s included, and the style values each copy gets from its `style` attribute and from the style sheet rules that match it. An SVG that expands to more than 999,999 elements (500,000 on the web) or to more than 64 MB of copied style values is refused with an error, and so is a loop of `` references that would be copied without end; both limits are VectorCraft's own. An SVG shown as an image counts toward the same 64 MB as the SVG showing it, and one past either limit refuses the whole import (`crates/svg/src/import/budget.rs`, `crates/svg/tests/it/import_details.rs`). - SVG import reads the files that a linked SVG file's own `` elements link to, relative paths from that file's folder (), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. From 6b9564c60d1ec480c48a73b6b1341ade6c6f12ad Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:48:45 -0800 Subject: [PATCH 48/82] PDF: PDF/X-1a test covers a tagged gray JPEG The PDF/X-1a case of tagged_tiffs_keep_their_icc_profile also exports a gray JPEG with a gray profile, kept as it is, resampled and compressed with JPEG, and checks that it is written in DeviceGray without the profile. The comments on decoded_image, recode and the test say that PDF/X-1a allows no ICC-based color spaces. --- crates/pdf/src/images.rs | 6 +++--- crates/pdf/src/tests/compression.rs | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index eee79ee3bb..0085f07538 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -144,8 +144,8 @@ pub(crate) fn from_decoded(img: DynamicImage, icc: Option>, interpolate: } /// Image `bytes` decoded within [`limits`], with the ICC profile it carries ([`source_icc`]) when -/// `profiles` allows it (not in PDF/X-1a, which has device colours only), as [`from_decoded`] -/// writes it. `None` when it can't be decoded. +/// `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces), as +/// [`from_decoded`] writes it. `None` when it can't be decoded. pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { from_decoded(decoded(bytes)?, profiles.then(|| source_icc(bytes)).flatten(), interpolate, sixteen_bit) } @@ -371,7 +371,7 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic /// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask: /// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours /// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile -/// they carry when `profiles` allows it (not in PDF/X-1a, which has device colours only). +/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces). /// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. pub(crate) fn recode( bytes: &[u8], diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 30e9d57e4b..8b88a0731d 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -292,6 +292,11 @@ fn tagged(img: &DynamicImage, icc: &[u8], format: ImageFormat) -> Vec { enc.set_icc_profile(icc.to_vec()).unwrap(); img.write_with_encoder(enc).unwrap(); } + ImageFormat::Jpeg => { + let mut enc = image::codecs::jpeg::JpegEncoder::new(&mut out); + enc.set_icc_profile(icc.to_vec()).unwrap(); + img.write_with_encoder(enc).unwrap(); + } _ => { let mut enc = image::codecs::png::PngEncoder::new(&mut out); enc.set_icc_profile(icc.to_vec()).unwrap(); @@ -423,11 +428,12 @@ fn tagged_tiffs_keep_their_icc_profile() { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } - // PDF/X-1a has device colours only: a tagged gray TIFF or PNG is written in DeviceGray, kept as - // it is, resampled or compressed with JPEG. + // PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF, PNG or JPEG is written in + // DeviceGray, kept as it is, resampled or compressed with JPEG. let compressions = [json!({}), json!({"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"gray": {"compression": "jpeg"}})]; let png = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png"); - for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png)] { + let jpeg = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Jpeg), "image/jpeg"); + for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png), ("JPEG", jpeg)] { for compression in &compressions { let mut settings: PdfSettings = serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); From 54f4c256eb0b9f1bf04cb38f66bea86113d55d36 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:49:21 -0800 Subject: [PATCH 49/82] Type: a tabbed right-to-left line ending at a final soft hyphen draws no hyphen Where a tabbed line with glyphs stepping back stops reading at its break limit on a soft hyphen that ends the text, it now ends there without a hyphen, as a line that reads to the end of the text does. --- crates/text/src/layout/breaking.rs | 3 ++- crates/text/src/layout/wrapping_tests.rs | 26 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 16ac9fc6c6..4b89f79ae6 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -525,9 +525,10 @@ pub(super) fn break_line( // A line with a glyph stepping back stops reading MAX_DRAWN_CHECKS_STEPPING_BACK // breaks past where it first overflowed in the order of its text, and ends at the // latest break read that fits as drawn: each of those breaks is measured one by one. + // A soft hyphen ending the text draws no hyphen there, as at the end of the text below. breaks_past_overflow += usize::from(overflowed && back > 0.0); if breaks_past_overflow >= MAX_DRAWN_CHECKS_STEPPING_BACK { - return settle(tabbed, checks(back), (cluster_end, hyphenated), &breaks); + return settle(tabbed, checks(back), (cluster_end, hyphenated && cluster_end < g.len()), &breaks); } } } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e27a77828f..be089b4434 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -719,6 +719,32 @@ fn a_tabbed_line_stepping_back_ends_at_the_latest_break_read_that_fits() { assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 3075), (3075, false)); } +/// A tabbed line with glyphs stepping back that stops reading at a soft hyphen ending the text ends +/// there without a hyphen, as a line that reads to the end of the text does. +#[test] +fn a_tabbed_line_stepping_back_ends_at_a_final_soft_hyphen_without_a_hyphen() { + // The 1,023 spaces and the soft hyphen are the 1,024 breaks the line reads past its overflow. + let text = format!("a\ty{}b\u{00AD}", "bz ".repeat(1023)); + let specs: Vec<_> = text + .char_indices() + .map(|(byte, ch)| { + let adv = match ch { + 'y' | 'z' => -1.0, + ' ' | '\u{00AD}' => 0.0, + _ => 1.0, + }; + (byte, ch.len_utf8(), adv, ch) + }) + .collect(); + let glyphs = synthetic_glyphs(&specs); + let len = glyphs.len(); + let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end == len; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (len, 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] From 1bd269b4ed7bf07941a20c48b73118b8eba49e2d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:09:09 -0800 Subject: [PATCH 50/82] Type: Smart Punctuation replaces ligatures, extra spaces and fractions and reports results Type > Smart Punctuation... now opens its own dialog in place of the generic parameter form. It has the seven replacement options, Replace In (Selected Text Only or Entire Document) and Report Results, which lists how many of each chosen replacement were made after OK. The dialog opens on the options confirmed last. - type.smartPunctuation takes fiLigatures, flLigatures, spaces and fractions, all off by default, so existing calls behave as before. - Ligatures write U+FB00 to U+FB04, longest match first. Expert Fractions writes the one-character fractions Unicode encodes, and skips one with a digit, a slash or a decimal point next to it, across a change of style too. Either is written only where the run's font has a glyph for it. - Smart Spaces keeps one space after a period, across runs. An inline graphic separates the text on either side of it. - The reply adds a replaced object with a count per kind. Nothing to replace still records no undo step. Docs: docs/mcp.md, docs/type-parity.md. --- crates/engine/src/cmd/typemenu.rs | 289 ++++++++++++++++-- crates/engine/src/tests/menucmds.rs | 112 ++++++- crates/ui-egui/src/dialogs.rs | 2 + .../ui-egui/src/dialogs/smart_punctuation.rs | 169 ++++++++++ crates/ui-egui/src/i18n/bg.tsv | 12 + crates/ui-egui/src/i18n/bs.tsv | 12 + crates/ui-egui/src/i18n/ca.tsv | 12 + crates/ui-egui/src/i18n/cs.tsv | 12 + crates/ui-egui/src/i18n/da.tsv | 12 + crates/ui-egui/src/i18n/de.tsv | 12 + crates/ui-egui/src/i18n/el.tsv | 12 + crates/ui-egui/src/i18n/es.tsv | 12 + crates/ui-egui/src/i18n/fi.tsv | 12 + crates/ui-egui/src/i18n/fr.tsv | 12 + crates/ui-egui/src/i18n/gu.tsv | 12 + crates/ui-egui/src/i18n/hi.tsv | 12 + crates/ui-egui/src/i18n/hu.tsv | 12 + crates/ui-egui/src/i18n/id.tsv | 12 + crates/ui-egui/src/i18n/it.tsv | 12 + crates/ui-egui/src/i18n/ja.tsv | 12 + crates/ui-egui/src/i18n/ka.tsv | 12 + crates/ui-egui/src/i18n/ko.tsv | 12 + crates/ui-egui/src/i18n/nl.tsv | 12 + crates/ui-egui/src/i18n/pl.tsv | 12 + crates/ui-egui/src/i18n/pt-br.tsv | 12 + crates/ui-egui/src/i18n/ru.tsv | 12 + crates/ui-egui/src/i18n/sv.tsv | 12 + crates/ui-egui/src/i18n/tr.tsv | 12 + crates/ui-egui/src/i18n/uk.tsv | 12 + crates/ui-egui/src/i18n/vi.tsv | 12 + crates/ui-egui/src/i18n/zh-hans.tsv | 12 + crates/ui-egui/src/i18n/zh-hant.tsv | 12 + crates/ui-egui/src/menus/invoke.rs | 2 + crates/ui-egui/src/menus/tree.rs | 2 +- crates/ui-egui/src/palette.rs | 7 +- crates/ui-egui/src/shortcuts/tests.rs | 4 +- crates/ui-egui/src/state.rs | 4 + docs/mcp.md | 27 ++ docs/type-parity.md | 2 +- 39 files changed, 930 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/smart_punctuation.rs diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index c140ba6572..e7b25778ef 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Smart Punctuation…", ["Type"], None, - "{quotes?: true (straight quotes become the Document Setup quotes), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}", + "{fiLigatures?: false (ff, fi, ffi → the ligatures U+FB00, U+FB01, U+FB03), flLigatures?: false (ff, fl, ffl → U+FB00, U+FB02, U+FB04), quotes?: true (straight quotes become the Document Setup quotes), spaces?: false (two or more spaces after a period → one), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), fractions?: false (1/2, 1/4, 3/4 and the other fractions Unicode encodes as one character, with no digit, slash or decimal point next to them, → ½, ¼, ¾ …), scope?: \"selection\"|\"document\"} the selected type, or every text object with scope document or nothing selected; a ligature or a fraction is written only where the run's font has a glyph for it. Nothing to replace records no undo step → {changed: text objects changed, replaced: {fiLigatures, flLigatures, quotes, spaces, dashes, ellipses, fractions}: how many of each were replaced}", has_doc, smart_punctuation ), @@ -268,24 +268,226 @@ fn change_case(s: &mut Session, p: &Value) -> Result { // ---------- Smart Punctuation ---------- -#[derive(Default)] -struct PunctState { - prev: Option, +/// What Smart Punctuation replaces: each option of its dialog. +struct Punct { + /// ff, fi and ffi become the ligatures U+FB00, U+FB01 and U+FB03. + fi_ligatures: bool, + /// ff, fl and ffl become the ligatures U+FB00, U+FB02 and U+FB04. + fl_ligatures: bool, + /// Straight quotes become these quotes (the Document Setup quotes). + quotes: Option, + /// Two or more spaces after a period become one. + spaces: bool, + /// `--` becomes an en dash and `---` an em dash. + dashes: bool, + /// `...` becomes an ellipsis. + ellipses: bool, + /// `1/2` and the other fractions [`FRACTIONS`] lists become their one-character forms. + fractions: bool, } -/// Dashes (`--` en, `---` em), ellipses and, with `quotes`, typographer's quotes. -pub(crate) fn smarten(text: &str, prev: &mut Option, quotes: Option<&Quotes>, dashes: bool, ellipsis: bool) -> String { - let mut s = text.to_string(); - if dashes { - s = s.replace("---", "—").replace("--", "–"); +/// How many of each replacement Smart Punctuation made. +#[derive(Default)] +struct PunctCounts { + fi_ligatures: usize, + fl_ligatures: usize, + quotes: usize, + spaces: usize, + dashes: usize, + ellipses: usize, + fractions: usize, +} + +/// The text before the run being rewritten, across the runs of one text object. +#[derive(Default)] +struct PunctState { + /// The character before the run. + prev: Option, + /// The number of spaces since the last period, while only spaces follow it. + after_period: Option, + /// An inline graphic comes right before the run. + after_graphic: bool, +} + +/// The fractions Expert Fractions writes as one character: every fraction Unicode encodes as a +/// vulgar fraction with a numerator of 1 to 7 and a denominator of 2 to 10. +const FRACTIONS: [(&str, char); 18] = [ + ("1/2", '½'), + ("1/3", '⅓'), + ("2/3", '⅔'), + ("1/4", '¼'), + ("3/4", '¾'), + ("1/5", '⅕'), + ("2/5", '⅖'), + ("3/5", '⅗'), + ("4/5", '⅘'), + ("1/6", '⅙'), + ("5/6", '⅚'), + ("1/7", '⅐'), + ("1/8", '⅛'), + ("3/8", '⅜'), + ("5/8", '⅝'), + ("7/8", '⅞'), + ("1/9", '⅑'), + ("1/10", '⅒'), +]; + +/// `s` with every `from` replaced by `to`, adding the number replaced to `n`. +fn replace_counted(s: String, from: &str, to: &str, n: &mut usize) -> String { + let found = s.matches(from).count(); + if found == 0 { + return s; } - if ellipsis { - s = s.replace("...", "…"); + *n += found; + s.replace(from, to) +} + +/// Smart Spaces: `text` without the second and later spaces after a period, adding the number of +/// periods whose spaces it shortened to `n`. `after` carries the spaces after a period from one run +/// to the next. +fn smart_spaces(text: &str, after: &mut Option, n: &mut usize) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match (c, *after) { + (' ', Some(k)) => { + *after = Some(k + 1); + if k >= 1 { + if k == 1 { + *n += 1; + } + continue; + } + } + ('.', _) => *after = Some(0), + _ => *after = None, + } + out.push(c); } - match quotes { - Some(q) => q.apply(&s, prev), + out +} + +/// The fraction `cs` starts with, and its length in characters: one of [`FRACTIONS`] that no digit, +/// slash or decimal part follows, so `1/25`, `1/2/2026` and `1/2.5` are left as they are. +fn fraction_at(cs: &[char]) -> Option<(usize, char)> { + FRACTIONS.iter().find_map(|(text, f)| { + let len = text.chars().count(); + if cs.len() < len || !cs[..len].iter().copied().eq(text.chars()) { + return None; + } + let ends = match &cs[len..] { + [c, ..] if c.is_ascii_digit() || *c == '/' => false, + ['.' | ',', d, ..] if d.is_ascii_digit() => false, + _ => true, + }; + ends.then_some((len, *f)) + }) +} + +/// Expert Fractions: `text` with each fraction [`fraction_at`] finds, and no digit, slash or +/// decimal point before, written as one character when `covers` it. `prev` is the character before +/// `text`, and `next` the two characters after it (in the next runs), so a fraction at the end of +/// a run is checked against what follows it. +fn expert_fractions(text: &str, prev: Option, next: [Option; 2], covers: &mut impl FnMut(char) -> bool, n: &mut usize) -> String { + let mut cs: Vec = text.chars().collect(); + let len_text = cs.len(); + cs.extend(next.into_iter().flatten()); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < len_text { + let before = if i == 0 { prev } else { Some(cs[i - 1]) }; + let free = !before.is_some_and(|c| c.is_ascii_digit() || matches!(c, '/' | '.' | ',')); + let found = fraction_at(&cs[i..]).filter(|(len, f)| i + len <= len_text && covers(*f)); + if free && let Some((len, f)) = found { + out.push(f); + *n += 1; + i += len; + continue; + } + out.push(cs[i]); + i += 1; + } + out +} + +/// The ligatures and their letters, longest first, with whether each comes with the ff, fi, ffi +/// option (else with the ff, fl, ffl option). ff comes with both. +const LIGATURES: [(&str, char, bool); 5] = + [("ffi", '\u{FB03}', true), ("ffl", '\u{FB04}', false), ("ff", '\u{FB00}', true), ("fi", '\u{FB01}', true), ("fl", '\u{FB02}', false)]; + +/// The ligature options: `text` with the letters of each ligature an option `o` asks for written +/// as the ligature, the longest first, when `covers` it. +fn ligatures(text: &str, o: &Punct, covers: &mut impl FnMut(char) -> bool, n: &mut PunctCounts) -> String { + let cs: Vec = text.chars().collect(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < cs.len() { + let found = (cs[i] == 'f').then(|| { + LIGATURES.iter().find(|(letters, lig, fi)| { + let on = if *letters == "ff" { + o.fi_ligatures || o.fl_ligatures + } else if *fi { + o.fi_ligatures + } else { + o.fl_ligatures + }; + let len = letters.chars().count(); + on && cs.len() - i >= len && cs[i..i + len].iter().copied().eq(letters.chars()) && covers(*lig) + }) + }); + if let Some(&(letters, lig, fi)) = found.flatten() { + out.push(lig); + // ff counts with the ff, fi, ffi option when that option is on. + if fi && (letters != "ff" || o.fi_ligatures) { + n.fi_ligatures += 1; + } else { + n.fl_ligatures += 1; + } + i += letters.chars().count(); + continue; + } + out.push(cs[i]); + i += 1; + } + out +} + +/// One run's text with the replacements `o` asks for, counted in `n`. Ligatures and fractions are +/// written only where the run's font has a glyph for them. `next` holds the two characters after +/// the run. +fn smarten(text: &str, style: &CharStyle, next: [Option; 2], o: &Punct, st: &mut PunctState, n: &mut PunctCounts) -> String { + let mut s = if o.spaces { smart_spaces(text, &mut st.after_period, &mut n.spaces) } else { text.to_string() }; + if o.dashes { + s = replace_counted(s, "---", "—", &mut n.dashes); + s = replace_counted(s, "--", "–", &mut n.dashes); + } + if o.ellipses { + s = replace_counted(s, "...", "…", &mut n.ellipses); + } + // The run's font, looked up once and only for a ligature or a fraction. + let mut face = None; + let mut covers = |c: char| { + face.get_or_insert_with(|| { + vectorcraft_text::FontDb::global().face_version(&style.font_family, &style.font_style, style.font_version.as_deref()) + }) + .as_ref() + .is_some_and(|f| f.covers(c)) + }; + if o.fractions && s.contains('/') { + let before = if st.after_graphic { None } else { st.prev }; + s = expert_fractions(&s, before, next, &mut covers, &mut n.fractions); + } + st.after_graphic = false; + if (o.fi_ligatures || o.fl_ligatures) && s.contains('f') { + s = ligatures(&s, o, &mut covers, n); + } + match &o.quotes { + Some(q) => { + let out = q.apply(&s, &mut st.prev); + n.quotes += s.chars().zip(out.chars()).filter(|(a, b)| a != b).count(); + out + } None => { - *prev = s.chars().last().or(*prev); + st.prev = s.chars().last().or(st.prev); s } } @@ -293,12 +495,20 @@ pub(crate) fn smarten(text: &str, prev: &mut Option, quotes: Option<&Quote fn smart_punctuation(s: &mut Session, p: &Value) -> Result { const C: &str = "type.smartPunctuation"; - let (d, e) = (switch_or(p, "dashes", true, C)?, switch_or(p, "ellipsis", true, C)?); let quotes = switch_or(p, "quotes", true, C)?; + let mut o = Punct { + fi_ligatures: switch_or(p, "fiLigatures", false, C)?, + fl_ligatures: switch_or(p, "flLigatures", false, C)?, + quotes: None, + spaces: switch_or(p, "spaces", false, C)?, + dashes: switch_or(p, "dashes", true, C)?, + ellipses: switch_or(p, "ellipsis", true, C)?, + fractions: switch_or(p, "fractions", false, C)?, + }; let document = choice(p, C, "scope", &["selection", "document"])? == Some("document"); let st = s.doc()?; // Quotes in the document's style (Document Setup → Type). - let q = quotes.then_some(st.doc.setup.quotes); + o.quotes = quotes.then_some(st.doc.setup.quotes); let ids = if document || st.selection.is_empty() { let mut v = vec![]; st.doc.walk(|n| { @@ -310,8 +520,51 @@ fn smart_punctuation(s: &mut Session, p: &Value) -> Result { } else { text_ids(&st.doc, &st.selection.objects) }; - let n = edit_runs::(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q.as_ref(), d, e))?; - Ok(json!({ "changed": n })) + let mut n = PunctCounts::default(); + let changed = super::overprint::edit_counted(s, "Smart Punctuation", |d| { + let mut changed = 0; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let mut st = PunctState::default(); + let mut any = false; + // Inline graphics are art, not characters to rewrite. Each run sees the first two + // characters of the runs after it, up to the next inline graphic, as they were. + for i in 0..t.runs.len() { + if t.runs[i].inline.is_some() { + // A graphic separates the characters on either side of it for Smart Spaces and + // Expert Fractions; quotes still read the character before it. + st.after_period = None; + st.after_graphic = true; + continue; + } + let mut after = t.runs[i + 1..].iter().take_while(|r| r.inline.is_none()).flat_map(|r| r.text.chars()); + let next = [after.next(), after.next()]; + let r = &mut t.runs[i]; + let new = smarten(&r.text, &r.style, next, &o, &mut st, &mut n); + if new != r.text { + r.text = new; + any = true; + } + } + if any { + changed += 1; + refresh_bounds(t); + } + } + Ok(changed) + })?; + Ok(json!({ + "changed": changed, + "replaced": { + "fiLigatures": n.fi_ligatures, + "flLigatures": n.fl_ligatures, + "quotes": n.quotes, + "spaces": n.spaces, + "dashes": n.dashes, + "ellipses": n.ellipses, + "fractions": n.fractions, + }, + })) } // ---------- Area / Point conversion ---------- diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index abb0bf31eb..90ac750157 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -2226,6 +2226,10 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { ("type.smartPunctuation", json!({"dashes": "false"}), switch("dashes")), ("type.smartPunctuation", json!({"ellipsis": 0}), switch("ellipsis")), ("type.smartPunctuation", json!({"quotes": "no"}), switch("quotes")), + ("type.smartPunctuation", json!({"fiLigatures": 1}), switch("fiLigatures")), + ("type.smartPunctuation", json!({"flLigatures": "true"}), switch("flLigatures")), + ("type.smartPunctuation", json!({"spaces": "on"}), switch("spaces")), + ("type.smartPunctuation", json!({"fractions": [true]}), switch("fractions")), ("type.smartPunctuation", json!({"scope": 5}), scope.clone()), ("type.smartPunctuation", json!({"scope": "page"}), scope), ("type.changeCase", json!({"case": 5}), string("case")), @@ -3555,12 +3559,118 @@ fn smart_punctuation_with_nothing_to_change_records_no_undo_step() { for id in [t, r] { select_with_a_step_to_redo(&mut s, &[id]); let before = history(&s); - assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap(), json!({"changed": 0}), "{id:?}"); + let r = s.execute("type.smartPunctuation", &json!({})).unwrap(); + assert_eq!(r["changed"], 0, "{id:?}"); + assert!(r["replaced"].as_object().unwrap().values().all(|n| n == 0), "{id:?}: {r}"); assert_eq!(history(&s), before, "{id:?}"); } assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…"); } +/// Does the face text `font` draws with have a glyph for every character of `chars`? +fn font_covers(font: &str, chars: &str) -> bool { + let face = vectorcraft_text::FontDb::global().face(font, "Regular"); + face.is_some_and(|f| chars.chars().all(|c| f.covers(c))) +} + +/// Every option on: the ligatures, Smart Spaces and Expert Fractions, each counted on its own, in +/// one undo step. Fractions next to a digit, a slash or a decimal part are left as they are. +#[test] +fn smart_punctuation_ligatures_spaces_and_fractions_are_counted() { + let mut s = session(); + assert!(font_covers("Source Sans 3", "\u{FB00}\u{FB01}\u{FB02}\u{FB03}\u{FB04}½¾"), "Source Sans 3 draws the ligatures and fractions"); + let words = "An office's fluffy flow. Then 1/2 and 3/4, not 1/25, 1/2/26 or 1/2.5."; + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": words, "font": "Source Sans 3"})).unwrap()); + sel(&mut s, &[t]); + let undo = history(&s).0; + let all = json!({"fiLigatures": true, "flLigatures": true, "quotes": true, "spaces": true, "dashes": true, "ellipsis": true, "fractions": true}); + let r = s.execute("type.smartPunctuation", &all).unwrap(); + assert_eq!(plain(&s, t), "An o\u{FB03}ce’s \u{FB02}u\u{FB00}y \u{FB02}ow. Then ½ and ¾, not 1/25, 1/2/26 or 1/2.5."); + let replaced = json!({"fiLigatures": 2, "flLigatures": 2, "quotes": 1, "spaces": 1, "dashes": 0, "ellipses": 0, "fractions": 2}); + assert_eq!(r, json!({"changed": 1, "replaced": replaced})); + assert_eq!(history(&s).0, undo + 1); +} + +/// The ff, fl, ffl option alone leaves fi and ffi as letters (ffi keeps its ff ligature, counted +/// with this option), and the other options are off unless asked for. +#[test] +fn smart_punctuation_fl_ligatures_alone() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "office fit baffle. 1/2", "font": "Source Sans 3"})).unwrap()); + sel(&mut s, &[t]); + let r = s.execute("type.smartPunctuation", &json!({"flLigatures": true})).unwrap(); + assert_eq!(plain(&s, t), "o\u{FB00}ice fit ba\u{FB04}e. 1/2"); + assert_eq!(r["replaced"], json!({"fiLigatures": 0, "flLigatures": 2, "quotes": 0, "spaces": 0, "dashes": 0, "ellipses": 0, "fractions": 0})); +} + +/// A ligature or a fraction the run's font has no glyph for stays as its letters, while the run's +/// other replacements still happen. +#[test] +fn smart_punctuation_skips_ligatures_and_fractions_the_font_lacks() { + let mut s = session(); + let mono = "JetBrains Mono"; + assert!(!font_covers(mono, "\u{FB01}") && !font_covers(mono, "⅐"), "{mono} has no fi ligature or one-seventh"); + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "fit 1/7 a--b", "font": mono})).unwrap()); + sel(&mut s, &[t]); + let r = s.execute("type.smartPunctuation", &json!({"fiLigatures": true, "fractions": true})).unwrap(); + assert_eq!(plain(&s, t), "fit 1/7 a–b"); + let counts = (&r["replaced"]["fiLigatures"], &r["replaced"]["fractions"], &r["replaced"]["dashes"]); + assert_eq!(counts, (&json!(0), &json!(0), &json!(1))); +} + +/// A fraction at the end of a run is checked against the start of the next run: 1/25 and 1/2.5 +/// split after their 1/2 stay as they are, and a 1/2 the next run's space ends is replaced. +#[test] +fn expert_fractions_look_past_the_end_of_a_run() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "1/25 1/2.5 1/2 x", "font": "Source Sans 3"})).unwrap()); + for (start, end) in [(0, 3), (5, 8), (11, 14)] { + s.execute("text.setRangeStyle", &json!({"id": t.0, "start": start, "end": end, "size": 30})).unwrap(); + } + sel(&mut s, &[t]); + let r = s.execute("type.smartPunctuation", &json!({"fractions": true})).unwrap(); + assert_eq!(plain(&s, t), "1/25 1/2.5 ½ x"); + assert_eq!(r["replaced"]["fractions"], 1); +} + +/// An inline graphic separates the text on either side of it: the space after it is not a second +/// space after the period before it, and the digit before it does not stop a fraction after it. +#[test] +fn an_inline_graphic_separates_smart_spaces_and_fractions() { + let mut s = session(); + let r = rect(&mut s, 600.0, 500.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "A. B 71/2", "font": "Source Sans 3"})).unwrap()); + s.execute("text.insertInline", &json!({"id": t.0, "at": 7, "symbol": "Mark"})).unwrap(); + s.execute("text.insertInline", &json!({"id": t.0, "at": 3, "symbol": "Mark"})).unwrap(); + let texts = |s: &Session| match node(s, t).kind { + NodeKind::Text(o) => o.runs.iter().map(|r| if r.inline.is_some() { "[g]".to_string() } else { r.text.clone() }).collect::(), + _ => String::new(), + }; + assert_eq!(texts(&s), "A. [g] B 7[g]1/2"); + sel(&mut s, &[t]); + let r = s.execute("type.smartPunctuation", &json!({"spaces": true, "fractions": true})).unwrap(); + assert_eq!(texts(&s), "A. [g] B 7[g]½"); + assert_eq!((&r["replaced"]["spaces"], &r["replaced"]["fractions"]), (&json!(0), &json!(1))); +} + +/// Smart Spaces keeps one space after each period, also when the spaces start in the next run, +/// and leaves single spaces and spaces after other characters alone. +#[test] +fn smart_spaces_keeps_one_space_after_a_period() { + let mut s = session(); + let t = text(&mut s, "A. B. C. D E"); + // "C." is in a run of its own, and the spaces after it start the next one. + s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 8, "end": 10, "size": 30})).unwrap(); + assert!(matches!(node(&s, t).kind, NodeKind::Text(ref o) if o.runs.len() > 1)); + sel(&mut s, &[t]); + let r = s.execute("type.smartPunctuation", &json!({"spaces": true, "quotes": false, "dashes": false, "ellipsis": false})).unwrap(); + assert_eq!(plain(&s, t), "A. B. C. D E"); + assert_eq!(r["replaced"]["spaces"], 2); +} + #[test] fn convert_point_area_roundtrip() { let mut s = session(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5afad21b0d..0d28eacc91 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -122,6 +122,7 @@ pub(crate) mod shapes; pub mod shortcut_import; pub(crate) mod simplify_slider; pub mod slices; +pub mod smart_punctuation; pub(crate) mod smooth_slider; pub mod spot_colors; pub mod svg_filter; @@ -430,6 +431,7 @@ registry! { VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, + SmartPunctuation: [smart_punctuation::KIND] => smart_punctuation::SPEC, PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC, LiquifyOptions: [liquify::KIND] => liquify::SPEC, diff --git a/crates/ui-egui/src/dialogs/smart_punctuation.rs b/crates/ui-egui/src/dialogs/smart_punctuation.rs new file mode 100644 index 0000000000..1ebe084e02 --- /dev/null +++ b/crates/ui-egui/src/dialogs/smart_punctuation.rs @@ -0,0 +1,169 @@ +//! Type › Smart Punctuation…: which keyboard characters to replace with their typographic forms, +//! where (the selected type or the whole document) and whether to report how many of each were +//! replaced. OK runs `type.smartPunctuation` as one undo step, and the dialog opens on the fields +//! confirmed last. +//! +//! Fields: the switches `fiLigatures`, `flLigatures`, `quotes`, `spaces`, `dashes`, `ellipsis` and +//! `fractions`, `scope` (`selection` or `document`) and `report`. + +use serde_json::{Value, json}; + +use super::{DialogResult, DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Smart Punctuation. +pub const KIND: &str = "smartPunctuation"; + +const CMD: &str = "type.smartPunctuation"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Smart Punctuation").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM }; + +/// The replacement options in the dialog's order: (field, the key of their count in the +/// command's reply, label). +fn options() -> [(&'static str, &'static str, &'static str); 7] { + [ + ("fiLigatures", "fiLigatures", tl!("ff, fi, ffi Ligatures")), + ("flLigatures", "flLigatures", tl!("ff, fl, ffl Ligatures")), + ("quotes", "quotes", tl!("Smart Quotes")), + ("spaces", "spaces", tl!("Smart Spaces")), + ("dashes", "dashes", tl!("En, Em Dashes")), + ("ellipsis", "ellipses", tl!("Ellipses")), + ("fractions", "fractions", tl!("Expert Fractions")), + ] +} + +/// The fields the menu item opens with: those confirmed last, else quotes, dashes and ellipses on +/// (the command's defaults). With nothing selected, the whole document. +pub fn fields(app: &VectorcraftApp) -> Value { + let mut f = match &app.ui.smart_punctuation { + Value::Object(o) => Value::Object(o.clone()), + _ => json!({ + "fiLigatures": false, + "flLigatures": false, + "quotes": true, + "spaces": false, + "dashes": true, + "ellipsis": true, + "fractions": false, + "scope": "selection", + "report": false, + }), + }; + if !has_selection(app) { + f["scope"] = json!("document"); + } + f +} + +fn has_selection(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|d| !d.selection.is_empty()) +} + +/// The command's parameters: the switches and the scope. +fn params(d: &Dialog) -> Value { + let mut p = serde_json::Map::new(); + for (key, ..) in options() { + p.insert(key.into(), json!(d.bool(key))); + } + p.insert("scope".into(), json!(if d.str("scope") == "document" { "document" } else { "selection" })); + Value::Object(p) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + for (key, _, label) in options() { + form::check(ui, d, key, label); + } + ui.add_space(8.0); + widgets::section_header(ui, tl!("Replace In")); + let selected = has_selection(app); + let scope = d.str("scope"); + for (value, label, enabled) in [("selection", tl!("Selected Text Only"), selected), ("document", tl!("Entire Document"), true)] { + let on = if selected { scope == value } else { value == "document" }; + if widgets::radio(ui, label, on, enabled) { + d.fields.insert("scope".into(), json!(value)); + } + } + ui.add_space(8.0); + form::check(ui, d, "report", tl!("Report Results")); + false +} + +/// Runs the command, keeps the fields for the next time, and with Report Results on lists how many +/// of each chosen replacement it made. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { + let r = run_and_close(app, CMD, params(d))?; + app.ui.smart_punctuation = Value::Object(d.fields.clone()); + if d.bool("report") { + let lines: Vec = options() + .into_iter() + .filter(|(key, ..)| d.bool(key)) + .map(|(_, count, label)| format!("{label}: {}", r["replaced"][count].as_u64().unwrap_or(0))) + .collect(); + super::confirm::tell(app, tl!("Smart Punctuation"), &lines.join("\n")); + } + Ok(r) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app_with_text(text: &str) -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": text, "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + (app, id) + } + + fn plain(app: &VectorcraftApp, id: u64) -> String { + match &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => t.plain_text(), + _ => String::new(), + } + } + + /// The menu item opens the dialog; OK replaces in one undo step, reports each chosen + /// replacement's count, and the next opening starts from the fields confirmed. + #[test] + fn menu_item_opens_the_dialog_and_ok_reports_the_counts() { + let (mut app, id) = app_with_text("\"Hi\". 1/2 -- off"); + app.run("select.set", json!({"ids": [id]})).unwrap(); + crate::menus::invoke(&mut app, CMD, json!({})); + let d = app.ui.dialog.as_mut().expect("Smart Punctuation opens"); + assert_eq!((d.kind.as_str(), d.str("scope")), (KIND, "selection".to_string())); + for key in ["spaces", "fractions", "fiLigatures", "report"] { + d.fields.insert(key.into(), json!(true)); + } + d.fields.insert("ellipsis".into(), json!(false)); + let undo = app.session.doc().unwrap().history.undo.len(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(plain(&app, id), "“Hi”. ½ – o\u{FB00}"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + let told = app.ui.dialog.as_ref().expect("Report Results shows the counts"); + assert_eq!(told.kind, crate::dialogs::confirm::MESSAGE); + assert_eq!(told.str("detail"), "ff, fi, ffi Ligatures: 1\nSmart Quotes: 2\nSmart Spaces: 1\nEn, Em Dashes: 1\nExpert Fractions: 1"); + app.ui.dialog = None; + crate::menus::invoke(&mut app, CMD, json!({})); + let again = app.ui.dialog.as_ref().unwrap(); + assert!(again.bool("fractions") && again.bool("report") && !again.bool("ellipsis")); + } + + /// With nothing selected the dialog replaces in the entire document, and without Report + /// Results it shows no message. An unchanged document keeps its undo history. + #[test] + fn with_nothing_selected_the_whole_document_is_replaced() { + let (mut app, id) = app_with_text("a--b"); + app.run("select.none", json!({})).unwrap(); + crate::menus::invoke(&mut app, CMD, json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("scope"), "document"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(plain(&app, id), "a–b"); + assert!(app.ui.dialog.is_none()); + let undo = app.session.doc().unwrap().history.undo.len(); + crate::menus::invoke(&mut app, CMD, json!({})); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28944173dd..7df9c6cc19 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3348,6 +3348,18 @@ digits Malayalam Малаялам Smart Guides (View → Smart Guides) Интелигентни водачи (Изглед → Интелигентни водачи) Smart Guides (⌘U) Интелигентни водачи (⌘U) Smart Punctuation… Интелигентна пунктуация… + Smart Punctuation Интелигентна пунктуация + ff, fi, ffi Ligatures Лигатури ff, fi, ffi + ff, fl, ffl Ligatures Лигатури ff, fl, ffl + Smart Quotes Интелигентни кавички + Smart Spaces Интелигентни интервали + En, Em Dashes Средни и дълги тирета + Ellipses Многоточия + Expert Fractions Експертни дроби + Replace In Замяна в + Selected Text Only Само избрания текст + Entire Document Целия документ + Report Results Отчет за резултатите Smooth Гладък Corner Ъгъл Relative Относителен diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..2396bf7da0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -421,6 +421,18 @@ Title Case Svaka Riječ Velikim Slovom Sentence case Veliko početno slovo rečenice Smart Punctuation… Pametna interpunkcija… + Smart Punctuation Pametna interpunkcija + ff, fi, ffi Ligatures Ligature ff, fi, ffi + ff, fl, ffl Ligatures Ligature ff, fl, ffl + Smart Quotes Pametni navodnici + Smart Spaces Pametni razmaci + En, Em Dashes Srednje i duge crtice + Ellipses Tri tačke + Expert Fractions Stručni razlomci + Replace In Zamijeni u + Selected Text Only Samo odabrani tekst + Entire Document Cijeli dokument + Report Results Izvještaj o rezultatima Create Outlines Kreiraj obrise Optical Margin Alignment Optičko poravnanje margina Dynamic Text Dinamički tekst diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..3ddd057263 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4372,6 +4372,18 @@ digits Malayalam Malaiàlam Smart Guides (View → Smart Guides) Guies intel·ligents (Visualitza → Guies intel·ligents) Smart Guides (⌘U) Guies intel·ligents (⌘U) Smart Punctuation… Puntuació intel·ligent… + Smart Punctuation Puntuació intel·ligent + ff, fi, ffi Ligatures Lligadures ff, fi, ffi + ff, fl, ffl Ligatures Lligadures ff, fl, ffl + Smart Quotes Cometes intel·ligents + Smart Spaces Espais intel·ligents + En, Em Dashes Ratlles en i em + Ellipses Punts suspensius + Expert Fractions Fraccions expertes + Replace In Reemplaça a + Selected Text Only Només el text seleccionat + Entire Document Tot el document + Report Results Informa dels resultats Smooth Suavitza Smooth Color Color suau Smooth Tool Eina de suavitzat diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..73a1eab066 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -428,6 +428,18 @@ Title Case Velká Počáteční Písmena Sentence case Velké první písmeno věty Smart Punctuation… Inteligentní interpunkce… + Smart Punctuation Inteligentní interpunkce + ff, fi, ffi Ligatures Ligatury ff, fi, ffi + ff, fl, ffl Ligatures Ligatury ff, fl, ffl + Smart Quotes Inteligentní uvozovky + Smart Spaces Inteligentní mezery + En, Em Dashes Krátké a dlouhé pomlčky + Ellipses Výpustky + Expert Fractions Odborné zlomky + Replace In Nahradit v + Selected Text Only Pouze vybraný text + Entire Document Celý dokument + Report Results Zobrazit výsledky Create Outlines Převést na křivky Optical Margin Alignment Optické zarovnání okrajů Dynamic Text Dynamický text diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b3de0ae89a..33b235302b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -389,6 +389,18 @@ Title Case Stort bogstav i hvert ord Sentence case Stort begyndelsesbogstav Smart Punctuation… Smart tegnsætning… + Smart Punctuation Smart tegnsætning + ff, fi, ffi Ligatures Ligaturer ff, fi, ffi + ff, fl, ffl Ligatures Ligaturer ff, fl, ffl + Smart Quotes Smarte anførselstegn + Smart Spaces Smarte mellemrum + En, Em Dashes Korte og lange tankestreger + Ellipses Udeladelsesprikker + Expert Fractions Ekspertbrøker + Replace In Erstat i + Selected Text Only Kun markeret tekst + Entire Document Hele dokumentet + Report Results Rapportér resultater Create Outlines Opret konturer Optical Margin Alignment Optisk marginjustering Dynamic Text Dynamisk tekst diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..a3a1583a4e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3411,6 +3411,18 @@ effect Points Punkte Smart Guides (View → Smart Guides) Intelligente Hilfslinien (Ansicht → Intelligente Hilfslinien) Smart Guides (⌘U) Intelligente Hilfslinien (⌘U) Smart Punctuation… Typografische Satzzeichen… + Smart Punctuation Typografische Satzzeichen + ff, fi, ffi Ligatures Ligaturen ff, fi, ffi + ff, fl, ffl Ligatures Ligaturen ff, fl, ffl + Smart Quotes Typografische Anführungszeichen + Smart Spaces Intelligente Leerzeichen + En, Em Dashes Halbgeviert- und Geviertstriche + Ellipses Auslassungspunkte + Expert Fractions Expertenbrüche + Replace In Ersetzen in + Selected Text Only Nur ausgewählter Text + Entire Document Gesamtes Dokument + Report Results Ergebnisse anzeigen Smooth Glatt Corner Ecke Relative Relativ diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..6daf3e30e4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3981,6 +3981,18 @@ digits Malayalam Μαλαγιάλαμ Smart Guides (View → Smart Guides) Έξυπνοι οδηγοί (Προβολή → Έξυπνοι οδηγοί) Smart Guides (⌘U) Έξυπνοι οδηγοί (⌘U) Smart Punctuation… Έξυπνη στίξη… + Smart Punctuation Έξυπνη στίξη + ff, fi, ffi Ligatures Συμπλέγματα ff, fi, ffi + ff, fl, ffl Ligatures Συμπλέγματα ff, fl, ffl + Smart Quotes Έξυπνα εισαγωγικά + Smart Spaces Έξυπνα διαστήματα + En, Em Dashes Παύλες en και em + Ellipses Αποσιωπητικά + Expert Fractions Κλάσματα ειδικών + Replace In Αντικατάσταση σε + Selected Text Only Μόνο επιλεγμένο κείμενο + Entire Document Ολόκληρο το έγγραφο + Report Results Αναφορά αποτελεσμάτων Smooth Color Ομαλό χρώμα Smooth Tool Εξομάλυνση (Smooth Tool) Snap Curves To Lines Προσκόλληση καμπυλών σε γραμμές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..18432ff9dc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3441,6 +3441,18 @@ digits Malayalam Malayálam Smart Guides (View → Smart Guides) Guías inteligentes (Ver → Guías inteligentes) Smart Guides (⌘U) Guías inteligentes (⌘U) Smart Punctuation… Puntuación inteligente… + Smart Punctuation Puntuación inteligente + ff, fi, ffi Ligatures Ligaduras ff, fi, ffi + ff, fl, ffl Ligatures Ligaduras ff, fl, ffl + Smart Quotes Comillas inteligentes + Smart Spaces Espacios inteligentes + En, Em Dashes Semirrayas y rayas + Ellipses Puntos suspensivos + Expert Fractions Fracciones expertas + Replace In Reemplazar en + Selected Text Only Solo el texto seleccionado + Entire Document Todo el documento + Report Results Informar de los resultados Smooth Suavizar Corner Vértice Relative Relativo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..9d90c0fd2c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -425,6 +425,18 @@ Title Case Otsikon kirjainkoko Sentence case Virkkeen kirjainkoko Smart Punctuation… Älykäs välimerkitys… + Smart Punctuation Älykäs välimerkitys + ff, fi, ffi Ligatures Ligatuurit ff, fi, ffi + ff, fl, ffl Ligatures Ligatuurit ff, fl, ffl + Smart Quotes Älykkäät lainausmerkit + Smart Spaces Älykkäät välilyönnit + En, Em Dashes Lyhyet ja pitkät ajatusviivat + Ellipses Kolme pistettä + Expert Fractions Erikoismurtoluvut + Replace In Korvaa kohteessa + Selected Text Only Vain valittu teksti + Entire Document Koko asiakirja + Report Results Näytä tulokset Create Outlines Muunna ääriviivoiksi Optical Margin Alignment Optinen reunustasaus Dynamic Text Dynaaminen teksti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..02539c5c7f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3433,6 +3433,18 @@ digits Malayalam Malayalam Smart Guides (View → Smart Guides) Repères commentés (Affichage → Repères commentés) Smart Guides (⌘U) Repères commentés (⌘U) Smart Punctuation… Ponctuation typographique… + Smart Punctuation Ponctuation typographique + ff, fi, ffi Ligatures Ligatures ff, fi, ffi + ff, fl, ffl Ligatures Ligatures ff, fl, ffl + Smart Quotes Guillemets typographiques + Smart Spaces Espaces typographiques + En, Em Dashes Tirets demi-cadratin et cadratin + Ellipses Points de suspension + Expert Fractions Fractions expertes + Replace In Remplacer dans + Selected Text Only Texte sélectionné uniquement + Entire Document Document entier + Report Results Afficher les résultats Smooth Lisser Corner Angle Relative Relatif diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..3a9319b569 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -3561,6 +3561,18 @@ Smart Pen Panel સ્માર્ટ પેન પેનલ Smart Pen Tool સ્માર્ટ પેન ટૂલ Smart Punctuation… સ્માર્ટ વિરામચિહ્નો… + Smart Punctuation સ્માર્ટ વિરામચિહ્નો + ff, fi, ffi Ligatures ff, fi, ffi લિગેચર + ff, fl, ffl Ligatures ff, fl, ffl લિગેચર + Smart Quotes સ્માર્ટ અવતરણચિહ્નો + Smart Spaces સ્માર્ટ જગ્યાઓ + En, Em Dashes En અને Em ડૅશ + Ellipses એલિપ્સિસ + Expert Fractions નિષ્ણાત અપૂર્ણાંકો + Replace In આમાં બદલો + Selected Text Only માત્ર પસંદ કરેલું લખાણ + Entire Document આખો દસ્તાવેજ + Report Results પરિણામોનો અહેવાલ Smooth સ્મૂધ Smooth Anchors… એન્કર સ્મૂધ કરો… Smooth Tool સ્મૂધ ટૂલ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..34fc1749a8 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4726,6 +4726,18 @@ antiAlias Sharp शार्प Smart Pen Panel स्मार्ट पेन पैनल Smart Pen Tool स्मार्ट पेन टूल Smart Punctuation… स्मार्ट विराम-चिह्न… + Smart Punctuation स्मार्ट विराम-चिह्न + ff, fi, ffi Ligatures ff, fi, ffi लिगेचर + ff, fl, ffl Ligatures ff, fl, ffl लिगेचर + Smart Quotes स्मार्ट उद्धरण-चिह्न + Smart Spaces स्मार्ट स्पेस + En, Em Dashes एन और एम डैश + Ellipses दीर्घविराम + Expert Fractions विशेषज्ञ भिन्न + Replace In इसमें रिप्लेस करें + Selected Text Only केवल सिलेक्ट किया गया टेक्स्ट + Entire Document पूरा डॉक्यूमेंट + Report Results परिणाम रिपोर्ट करें Smooth स्मूथ Smooth Anchors चिकना एंकर Smooth Anchors… चिकना एंकर… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a1f6100b2f..a6b8ed8c1c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4093,6 +4093,18 @@ mark-style Japanese Japán Smart Guides (View → Smart Guides) Intelligens segédvonalak (Nézet → Intelligens segédvonalak) Smart Guides (⌘U) Intelligens segédvonalak (⌘U) Smart Punctuation… Intelligens írásjelek… + Smart Punctuation Intelligens írásjelek + ff, fi, ffi Ligatures ff, fi, ffi ligatúrák + ff, fl, ffl Ligatures ff, fl, ffl ligatúrák + Smart Quotes Intelligens idézőjelek + Smart Spaces Intelligens szóközök + En, Em Dashes Gondolatjelek és nagykötőjelek + Ellipses Három pont + Expert Fractions Szakértői törtek + Replace In Csere helye + Selected Text Only Csak a kijelölt szöveg + Entire Document Teljes dokumentum + Report Results Eredmények jelentése Smooth Simítás Smooth Color Sima színátmenet Smooth Tool Simítás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c7c6b956db..a4b3ca3624 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -389,6 +389,18 @@ Title Case Huruf besar di awal kata Sentence case Huruf besar di awal kalimat Smart Punctuation… Tanda baca cerdas… + Smart Punctuation Tanda baca cerdas + ff, fi, ffi Ligatures Ligatur ff, fi, ffi + ff, fl, ffl Ligatures Ligatur ff, fl, ffl + Smart Quotes Tanda kutip cerdas + Smart Spaces Spasi cerdas + En, Em Dashes Tanda pisah en dan em + Ellipses Elipsis + Expert Fractions Pecahan ahli + Replace In Ganti di + Selected Text Only Hanya teks terpilih + Entire Document Seluruh dokumen + Report Results Laporkan hasil Create Outlines Buat garis luar Optical Margin Alignment Perataan margin optik Dynamic Text Teks dinamis diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6743704785..39031f1778 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3486,6 +3486,18 @@ digits Malayalam Malayalam Smart Guides (View → Smart Guides) Guide sensibili (Visualizza → Guide sensibili) Smart Guides (⌘U) Guide sensibili (⌘U) Smart Punctuation… Punteggiatura intelligente… + Smart Punctuation Punteggiatura intelligente + ff, fi, ffi Ligatures Legature ff, fi, ffi + ff, fl, ffl Ligatures Legature ff, fl, ffl + Smart Quotes Virgolette intelligenti + Smart Spaces Spazi intelligenti + En, Em Dashes Trattini medi e lineette + Ellipses Puntini di sospensione + Expert Fractions Frazioni esperte + Replace In Sostituisci in + Selected Text Only Solo testo selezionato + Entire Document Intero documento + Report Results Mostra risultati Smooth Smussa Corner Angolo Relative Relativo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9cdb8fcbe..3811a8bf13 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -463,6 +463,18 @@ digits Malayalam マラヤーラム数字 Title Case 単語の先頭のみ大文字 Sentence case 文頭のみ大文字 Smart Punctuation… 欧文用字形の自動置換… + Smart Punctuation 欧文用字形の自動置換 + ff, fi, ffi Ligatures ff、fi、ffi 合字 + ff, fl, ffl Ligatures ff、fl、ffl 合字 + Smart Quotes スマート引用符 + Smart Spaces スマートスペース + En, Em Dashes EN ダッシュと EM ダッシュ + Ellipses 省略記号 + Expert Fractions エキスパート分数 + Replace In 置換範囲 + Selected Text Only 選択したテキストのみ + Entire Document ドキュメント全体 + Report Results 結果を表示 Create Outlines アウトラインを作成 Optical Margin Alignment 最適なマージン揃え Dynamic Text ダイナミックテキスト diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..679302903a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -354,6 +354,18 @@ Title Case სათაურის რეგისტრი Sentence case წინადადების რეგისტრი Smart Punctuation… ჭკვიანი პუნქტუაცია… + Smart Punctuation ჭკვიანი პუნქტუაცია + ff, fi, ffi Ligatures ლიგატურები ff, fi, ffi + ff, fl, ffl Ligatures ლიგატურები ff, fl, ffl + Smart Quotes ჭკვიანი ბრჭყალები + Smart Spaces ჭკვიანი ჰარები + En, Em Dashes მოკლე და გრძელი ტირეები + Ellipses მრავალწერტილები + Expert Fractions ექსპერტული წილადები + Replace In ჩანაცვლების არე + Selected Text Only მხოლოდ მონიშნული ტექსტი + Entire Document მთელი დოკუმენტი + Report Results შედეგების ანგარიში Create Outlines კონტურებად გარდაქმნა Optical Margin Alignment ოპტიკური არშიის სწორება Dynamic Text დინამიკური ტექსტი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..f14a1e35bb 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -456,6 +456,18 @@ digits Malayalam 말라얄람어 Title Case 제목 대소문자 Sentence case 문장 대소문자 Smart Punctuation… 스마트 문장 부호… + Smart Punctuation 스마트 문장 부호 + ff, fi, ffi Ligatures ff, fi, ffi 합자 + ff, fl, ffl Ligatures ff, fl, ffl 합자 + Smart Quotes 스마트 따옴표 + Smart Spaces 스마트 공백 + En, Em Dashes 엔 대시 및 엠 대시 + Ellipses 줄임표 + Expert Fractions 전문가 분수 + Replace In 바꿀 범위 + Selected Text Only 선택한 텍스트만 + Entire Document 전체 문서 + Report Results 결과 보고 Create Outlines 윤곽선 만들기 Optical Margin Alignment 시각적 여백 정렬 Dynamic Text 동적 텍스트 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..923fc14942 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -907,6 +907,18 @@ Title Case Koptitel Sentence case Zinsvorm Smart Punctuation… Slimme interpunctie… + Smart Punctuation Slimme interpunctie + ff, fi, ffi Ligatures Ligaturen ff, fi, ffi + ff, fl, ffl Ligatures Ligaturen ff, fl, ffl + Smart Quotes Slimme aanhalingstekens + Smart Spaces Slimme spaties + En, Em Dashes Halve en hele gedachtestreepjes + Ellipses Beletseltekens + Expert Fractions Expertbreuken + Replace In Vervangen in + Selected Text Only Alleen geselecteerde tekst + Entire Document Hele document + Report Results Resultaten rapporteren Create Outlines Omzetten in curven Optical Margin Alignment Optische marge-uitlijning Dynamic Text Dynamische tekst diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..513718a162 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -387,6 +387,18 @@ Title Case Jak W Tytule Sentence case Jak w zdaniu Smart Punctuation… Inteligentna interpunkcja… + Smart Punctuation Inteligentna interpunkcja + ff, fi, ffi Ligatures Ligatury ff, fi, ffi + ff, fl, ffl Ligatures Ligatury ff, fl, ffl + Smart Quotes Inteligentne cudzysłowy + Smart Spaces Inteligentne spacje + En, Em Dashes Półpauzy i pauzy + Ellipses Wielokropki + Expert Fractions Ułamki eksperckie + Replace In Zastąp w + Selected Text Only Tylko zaznaczony tekst + Entire Document Cały dokument + Report Results Raportuj wyniki Create Outlines Utwórz kontury Optical Margin Alignment Optyczne wyrównanie marginesów Dynamic Text Tekst dynamiczny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..a409fe4df2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2909,6 +2909,18 @@ digits Malayalam Malaiala Smart Guides (View → Smart Guides) Guias Inteligentes (Exibir → Guias Inteligentes) Smart Guides (⌘U) Guias Inteligentes (⌘U) Smart Punctuation… Pontuação Inteligente… + Smart Punctuation Pontuação Inteligente + ff, fi, ffi Ligatures Ligações ff, fi, ffi + ff, fl, ffl Ligatures Ligações ff, fl, ffl + Smart Quotes Aspas Inteligentes + Smart Spaces Espaços Inteligentes + En, Em Dashes Travessões En e Em + Ellipses Reticências + Expert Fractions Frações Especiais + Replace In Substituir Em + Selected Text Only Somente Texto Selecionado + Entire Document Documento Inteiro + Report Results Relatar Resultados Smooth Suavizar Smooth Tool Ferramenta Suavizar Snap Curves To Lines Ajustar Curvas às Linhas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..6801d120c5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3473,6 +3473,18 @@ digits Malayalam Малаялам Smart Guides (View → Smart Guides) Умные направляющие (Просмотр → Умные направляющие) Smart Guides (⌘U) Умные направляющие (⌘U) Smart Punctuation… Умная пунктуация… + Smart Punctuation Умная пунктуация + ff, fi, ffi Ligatures Лигатуры ff, fi, ffi + ff, fl, ffl Ligatures Лигатуры ff, fl, ffl + Smart Quotes Умные кавычки + Smart Spaces Умные пробелы + En, Em Dashes Средние и длинные тире + Ellipses Многоточия + Expert Fractions Экспертные дроби + Replace In Заменить в + Selected Text Only Только выделенный текст + Entire Document Весь документ + Report Results Отчёт о результатах Smooth Сгладить Corner Угол Relative Относительно diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..46fa719248 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3363,6 +3363,18 @@ digits Malayalam Malayalam Smart Guides (View → Smart Guides) Smarta stödlinjer (Visa → Smarta stödlinjer) Smart Guides (⌘U) Smarta stödlinjer (⌘U) Smart Punctuation… Smarta skiljetecken… + Smart Punctuation Smarta skiljetecken + ff, fi, ffi Ligatures Ligaturer ff, fi, ffi + ff, fl, ffl Ligatures Ligaturer ff, fl, ffl + Smart Quotes Smarta citattecken + Smart Spaces Smarta blanksteg + En, Em Dashes Korta och långa tankstreck + Ellipses Ellipser + Expert Fractions Expertbråk + Replace In Ersätt i + Selected Text Only Endast markerad text + Entire Document Hela dokumentet + Report Results Rapportera resultat Smooth Mjuk Corner Hörn Relative Relativ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..a19dddf225 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3239,6 +3239,18 @@ digits Malayalam Malayalam Smart Guides (View → Smart Guides) Akıllı Kılavuzlar (Görünüm → Akıllı Kılavuzlar) Smart Guides (⌘U) Akıllı Kılavuzlar (⌘U) Smart Punctuation… Akıllı Noktalama… + Smart Punctuation Akıllı Noktalama + ff, fi, ffi Ligatures ff, fi, ffi Bitişik Harfleri + ff, fl, ffl Ligatures ff, fl, ffl Bitişik Harfleri + Smart Quotes Akıllı Tırnaklar + Smart Spaces Akıllı Boşluklar + En, Em Dashes En ve Em Tireler + Ellipses Üç Noktalar + Expert Fractions Uzman Kesirleri + Replace In Değiştirilecek Yer + Selected Text Only Yalnızca Seçili Metin + Entire Document Tüm Belge + Report Results Sonuçları Bildir Smooth Pürüzsüz Corner Köşe Relative akraba diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..75cfb27300 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3479,6 +3479,18 @@ effect Points Точки Smart Guides (View → Smart Guides) Розумні напрямні (Перегляд → Розумні напрямні) Smart Guides (⌘U) Розумні напрямні (⌘U) Smart Punctuation… Розумна пунктуація… + Smart Punctuation Розумна пунктуація + ff, fi, ffi Ligatures Лігатури ff, fi, ffi + ff, fl, ffl Ligatures Лігатури ff, fl, ffl + Smart Quotes Розумні лапки + Smart Spaces Розумні пробіли + En, Em Dashes Середні й довгі тире + Ellipses Трикрапки + Expert Fractions Експертні дроби + Replace In Замінити в + Selected Text Only Тільки виділений текст + Entire Document Увесь документ + Report Results Звіт про результати Smooth Згладити Corner Кут Relative Відносно diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..14e9ca9e8f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3835,6 +3835,18 @@ mark-style Japanese Kiểu Nhật Smart Guides (View → Smart Guides) Đường căn thông minh (Hiển thị → Đường căn thông minh) Smart Guides (⌘U) Đường căn thông minh (⌘U) Smart Punctuation… Dấu câu thông minh… + Smart Punctuation Dấu câu thông minh + ff, fi, ffi Ligatures Chữ ghép ff, fi, ffi + ff, fl, ffl Ligatures Chữ ghép ff, fl, ffl + Smart Quotes Dấu ngoặc kép thông minh + Smart Spaces Khoảng trắng thông minh + En, Em Dashes Gạch ngang ngắn và dài + Ellipses Dấu chấm lửng + Expert Fractions Phân số chuyên dụng + Replace In Thay thế trong + Selected Text Only Chỉ văn bản đã chọn + Entire Document Toàn bộ tài liệu + Report Results Báo cáo kết quả Smooth Mượt Smooth Color Màu mượt Smooth Tool Công cụ Làm mượt diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..0453e6b907 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3302,6 +3302,18 @@ digits Malayalam 马拉雅拉姆数字 Smart Guides (View → Smart Guides) 智能参考线 (视图 → 智能参考线) Smart Guides (⌘U) 智能参考线 (⌘U) Smart Punctuation… 智能标点… + Smart Punctuation 智能标点 + ff, fi, ffi Ligatures ff、fi、ffi 连字 + ff, fl, ffl Ligatures ff、fl、ffl 连字 + Smart Quotes 智能引号 + Smart Spaces 智能空格 + En, Em Dashes 短破折号和长破折号 + Ellipses 删节号 + Expert Fractions 专家分数字 + Replace In 替换范围 + Selected Text Only 仅所选文本 + Entire Document 整个文档 + Report Results 报告结果 Smooth 平滑 Corner 尖角 Relative 相对 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..0b8cf754cb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4559,6 +4559,18 @@ digits Malayalam 馬來亞拉姆數字 Smart Guides (View → Smart Guides) 智慧型參考線 (檢視 → 智慧型參考線) Smart Guides (⌘U) 智慧型參考線 (⌘U) Smart Punctuation… 智慧型標點… + Smart Punctuation 智慧型標點 + ff, fi, ffi Ligatures ff、fi、ffi 連字 + ff, fl, ffl Ligatures ff、fl、ffl 連字 + Smart Quotes 智慧型引號 + Smart Spaces 智慧型空格 + En, Em Dashes 短破折號和長破折號 + Ellipses 刪節號 + Expert Fractions 專家分數 + Replace In 取代範圍 + Selected Text Only 僅限選取的文字 + Entire Document 整份文件 + Report Results 報告結果 Smooth 平滑 Corner 角點 Relative 相對 diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 3aab5e1e44..752d42b9ae 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -83,6 +83,8 @@ pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static st crate::addons::select::COMMAND => (crate::addons::select::KIND, crate::addons::select::fields(app)), "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::open_fields(app)), "artboard.rename" => (crate::dialogs::rename_artboards::KIND, crate::dialogs::rename_artboards::open_fields(app)), + // Smart Punctuation opens on the fields it was last confirmed with. + "type.smartPunctuation" => (crate::dialogs::smart_punctuation::KIND, crate::dialogs::smart_punctuation::fields(app)), _ => return None, }) } diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 4b86b4152d..cc63a489cf 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -436,7 +436,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { cp("Sentence case", "type.changeCase", json!({"case": "sentence"})), ], ), - cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), + c("Smart Punctuation…", "type.smartPunctuation"), sub( "Bullets and Numbering", vec![ diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index ded320a5aa..3e7fb53316 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -379,14 +379,15 @@ mod tests { ) } - /// A pick of a "…" command whose menu bar item gives params opens that item's dialog, and Smooth - /// opens its slider; nothing changes until the dialog is confirmed. + /// A pick of a "…" command whose menu bar item gives params opens that item's dialog, Smart + /// Punctuation opens its dialog and Smooth opens its slider; nothing changes until the dialog + /// is confirmed. #[test] fn a_pick_opens_the_dialog_its_menu_item_opens() { let expected = [ ("object.path.cleanUp", "command"), ("object.mesh.create", "command"), - ("type.smartPunctuation", "command"), + ("type.smartPunctuation", crate::dialogs::smart_punctuation::KIND), ("edit.findReplace", "command"), ("object.path.smooth", crate::dialogs::smooth_slider::KIND), ]; diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 92a1ab9768..10ed4efa13 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -297,8 +297,8 @@ fn a_shortcut_runs_what_its_menu_item_runs() { /// Vector Halftone, Rearrange All Artboards, Pathfinder Options, the Object › Plug-ins dialogs /// (Arrange in Grid, Replace with Top Object, the size, opacity and sort dialogs of the Arrange /// add-on, Set Corners…, Divide into Dashes, Find Nearest Swatch, the swatch legend, the type -/// tools, the select add-on and Select by Link Status…), four "…" items whose menu entry gives -/// params, Edit Custom Dictionary…, Composite Fonts…, Recolor Artwork…, the Edit menu's preset +/// tools, the select add-on and Select by Link Status…), three "…" items whose menu entry gives +/// params, Smart Punctuation…, Edit Custom Dictionary…, Composite Fonts…, Recolor Artwork…, the Edit menu's preset /// dialogs and Apply SVG Filter…. const MENU_DIALOG_COMMANDS: [&str; 39] = [ "object.path.smooth", diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 80a87b4563..b28e4034a7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -788,6 +788,9 @@ pub struct UiState { /// dialog opens on them (#1152). #[serde(default)] pub offset_path: Value, + /// The Smart Punctuation fields confirmed last (null: never used): its dialog opens on them. + #[serde(default)] + pub smart_punctuation: Value, /// The Average & Join fields confirmed last (`to`; null: never used): its dialog opens on them. #[serde(default)] pub average_join: Value, @@ -1226,6 +1229,7 @@ impl Default for UiState { dxf_options: Value::Null, eps_options: Value::Null, offset_path: Value::Null, + smart_punctuation: Value::Null, average_join: Value::Null, recolor_preserve: Value::Null, simplify: Value::Null, diff --git a/docs/mcp.md b/docs/mcp.md index 2e43c14d0e..16d419ad72 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8612,6 +8612,33 @@ on again. {"name":"run_command","arguments":{"command":"type.convertToAreaType","params":{"ids":[42]}}} ``` +## Smart Punctuation + +`type.smartPunctuation` replaces keyboard characters in the selected type, or in every text object with `scope: +"document"` or nothing selected, in one undo step. Each option is a switch: + +- `fiLigatures` (default `false`): ff, fi and ffi become the ligature characters U+FB00, U+FB01 and U+FB03. +- `flLigatures` (default `false`): ff, fl and ffl become U+FB00, U+FB02 and U+FB04. The longest match is taken first. +- `quotes` (default `true`): straight quotes become the Document Setup quotes. +- `spaces` (default `false`): two or more spaces after a period become one. +- `dashes` (default `true`): `--` becomes an en dash and `---` an em dash. +- `ellipsis` (default `true`): `...` becomes an ellipsis. +- `fractions` (default `false`): 1/2, 1/4, 3/4 and the other fractions Unicode encodes as one character (thirds, + fifths, sixths, sevenths, eighths, ninths and 1/10) become that character. A fraction with a digit, a slash or a + decimal point next to it, as in 1/25, 1/2/26 or 1/2.5, is left as it is, also when a change of style falls inside + it. An inline graphic separates the text before it from the text after it, for this option and for `spaces`. + +A ligature or a fraction is written only where the run's font has a glyph for it; elsewhere the letters stay. A value +other than true, false or null is refused. The reply gives the number of text objects changed and how many of each +replacement were made; with nothing to replace, no undo step is recorded. Type › Smart Punctuation… opens a dialog with +these options, Replace In (Selected Text Only or Entire Document) and Report Results, which lists the count of each +chosen replacement after OK. The dialog opens on the options confirmed last. + +```json +{"name":"run_command","arguments":{"command":"type.smartPunctuation","params":{"fiLigatures":true,"spaces":true,"fractions":true}}} +→ {"changed": 1, "replaced": {"fiLigatures": 2, "flLigatures": 0, "quotes": 1, "spaces": 1, "dashes": 0, "ellipses": 0, "fractions": 2}} +``` + ## Hyphenation options `text.setFormat` sets the Paragraph panel menu's Hyphenation… options of the selected type (or `ids`, or the diff --git a/docs/type-parity.md b/docs/type-parity.md index 72b8bcfc9d..64c6bcf798 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -51,7 +51,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred lines are still aligned as a whole); in justified area type the text up to a line's last tab keeps its spacing and its tabs their stops, and the text after the last tab takes the line's room to the far edge, VectorCraft's own rule (evidence: `tests::justify::a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge` and the other tab tests in `crates/text/src/tests/justify.rs`) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. Edit › Edit Custom Dictionary… lists the custom dictionary's words, with an Entry field and Add, Delete and Change, applied on Done (Cancel leaves the dictionary as it was). The Check Spelling dialog remains. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click), `crates/ui-egui/src/dialogs/custom_dictionary.rs` (the dialog). | -| Smart Punctuation, Change Case, Find & Replace | done | | +| Smart Punctuation, Change Case, Find & Replace | done | Type › Smart Punctuation… opens a dialog with the ff, fi, ffi and ff, fl, ffl ligatures, Smart Quotes, Smart Spaces, En, Em Dashes, Ellipses and Expert Fractions, Replace In and Report Results, which lists the count of each replacement; ligatures and fractions are written only where the font has a glyph for them. Evidence: `tests::menucmds::smart_punctuation_ligatures_spaces_and_fractions_are_counted`, `smart_punctuation_skips_ligatures_and_fractions_the_font_lacks`, `smart_spaces_keeps_one_space_after_a_period`, `dialogs::smart_punctuation::tests::menu_item_opens_the_dialog_and_ok_reports_the_counts` | | Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | | Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. | From f0c920fa8e44fe71c9b00d5ae67f47c8e3cea79f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:15:41 -0800 Subject: [PATCH 51/82] Shape Builder: leave out Live Paint faces and edges with an opacity of 0 A visible, unlocked Live Paint face or edge whose own opacity is 0 draws nothing, as Expand and the edge stroke check already treat it, but the Shape Builder still took it as an input. It now leaves such faces and edges out; hidden and locked parts keep their current handling. --- crates/engine/src/tests/build.rs | 15 ++++++++++++--- crates/tools/src/builder/lookup.rs | 8 ++++---- docs/target-app-parity.md | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index d16bdb537d..c87b8c85d7 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -4124,7 +4124,8 @@ fn shape_builder_uses_the_filled_faces_of_a_live_paint_group() { /// The Shape Builder takes a Live Paint face or edge when any of its fill or stroke rows draws, /// whichever row is on top: a painted lower row under an unpainted top row counts, and a part -/// whose only painted row is hidden, transparent or (for an edge) 0 pt wide does not. +/// whose only painted row is hidden, transparent or (for an edge) 0 pt wide, or whose own opacity +/// is 0, does not. #[test] fn shape_builder_takes_the_live_paint_faces_and_edges_that_draw() { use vectorcraft_doc::{FillLayer, StrokeLayer}; @@ -4154,15 +4155,23 @@ fn shape_builder_takes_the_live_paint_faces_and_edges_that_draw() { let hidden_stroke = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![hidden(stroke(red(), 2.0))], 1.0); let thin_stroke = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0); let clear_stroke = set_live_part(&mut s, g, None, Some((50.0, 50.0)), vec![clear(stroke(red(), 2.0))], 1.0); - let parts = [lower_fill, hidden_fill, clear_fill, lower_stroke, hidden_stroke, thin_stroke, clear_stroke]; + let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0); + let parts = [lower_fill, hidden_fill, clear_fill, lower_stroke, hidden_stroke, thin_stroke, clear_stroke, clear_edge]; assert_eq!(parts.iter().collect::>().len(), parts.len()); let d = s.doc().unwrap(); let (_, leaves) = vectorcraft_tools::builder::shape_builder_shapes(&d.doc, &[g], true); let taken: Vec = leaves.iter().map(|l| l.id).collect(); assert!(taken.contains(&lower_fill) && taken.contains(&lower_stroke), "a painted lower row draws: {taken:?}"); - for id in [hidden_fill, clear_fill, hidden_stroke, thin_stroke, clear_stroke] { + for id in [hidden_fill, clear_fill, hidden_stroke, thin_stroke, clear_stroke, clear_edge] { assert!(!taken.contains(&id), "{id:?} draws nothing: {taken:?}"); } + // A face with a painted fill whose own opacity is 0 is left out too. + let mut s = session(); + let g = live(&mut s); + let clear_face = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![fill(red())], 0.0); + let d = s.doc().unwrap(); + let (_, leaves) = vectorcraft_tools::builder::shape_builder_shapes(&d.doc, &[g], true); + assert!(!leaves.iter().any(|l| l.id == clear_face), "a face with an opacity of 0 draws nothing"); } /// An edge that closes on itself is a line around its face, not a second shape over it. diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 7a39214081..e8e261e5a4 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -505,8 +505,8 @@ fn collect_members<'a>(n: &'a Node, builder: bool, out: &mut Vec<&'a Node>) { NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), NodeKind::Group { .. } if builder && is_live_paint(n) => { - out.extend(faces(n).into_iter().filter(|f| !f.visible || f.locked || f.appearance.has_drawn_fill()).map(|f| &**f)); - out.extend(edges(n).into_iter().filter(|e| !e.visible || e.locked || e.appearance.has_drawn_stroke()).map(|e| &**e)); + out.extend(faces(n).into_iter().filter(|f| !f.visible || f.locked || (f.opacity > 0.0 && f.appearance.has_drawn_fill())).map(|f| &**f)); + out.extend(edges(n).into_iter().filter(|e| !e.visible || e.locked || (e.opacity > 0.0 && e.appearance.has_drawn_stroke())).map(|e| &**e)); } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { for c in children { @@ -591,8 +591,8 @@ fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], builder: bool) -> (Vec(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { let (mut shapes, leaves) = shapes_of(doc, roots, true); for (s, l) in shapes.iter_mut().zip(&leaves) { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 35bb9c8159..c269e14fd9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1196,7 +1196,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [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) describes (`path.freehand` `connect`). Double-clicking the Shape Builder tool opens Shape Builder Tool Options ([Shape Builder tool options](https://web.archive.org/web/20260105172244/https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html)): with Consider Open Filled Paths as Closed off, a filled open path cuts regions as an unfilled one does; Pick Color From Color Swatches fills merged shapes with the current fill, the Left and Right arrow keys step it through the document's color swatches, and Cursor Swatch Preview shows the current fill and the swatches before and after it above the pointer; Selection Straight Line merges what the line from a drag's first point to its last crosses ([Setting Illustrator's Shape Builder Options](https://web.archive.org/web/20260615204453/https://creativepro.com/setting-illustrators-shape-builder-options/)); with Highlight Fill off, merge mode shades no regions (`shapeBuilder.merge` `openFilledAsClosed` and `pickColorFrom`). - As VectorCraft's own rule, Consider Open Filled Paths as Closed counts an open path as filled when any of its fill rows draws (shown, painted and above 0% opacity), whichever row is on top, as the canvas paints it: a painted fill under an unpainted top fill closes the path, and a painted fill that is hidden or at 0% opacity does not. The region map, merges, erasing and Clicking Stroke Splits the Path follow that rule, and the Shape Builder takes the faces and edges of a Live Paint group that one of their fill or stroke rows draws. Regression tests: `tests::build::shape_builder_closes_open_paths_by_the_fill_rows_that_draw`, `tests::build::shape_builder_stroke_split_closes_open_paths_by_the_fill_rows_that_draw` and `tests::build::shape_builder_takes_the_live_paint_faces_and_edges_that_draw`. + As VectorCraft's own rule, Consider Open Filled Paths as Closed counts an open path as filled when any of its fill rows draws (shown, painted and above 0% opacity), whichever row is on top, as the canvas paints it: a painted fill under an unpainted top fill closes the path, and a painted fill that is hidden or at 0% opacity does not. The region map, merges, erasing and Clicking Stroke Splits the Path follow that rule, and the Shape Builder takes the faces and edges of a Live Paint group that one of their fill or stroke rows draws and whose own opacity is above 0. Regression tests: `tests::build::shape_builder_closes_open_paths_by_the_fill_rows_that_draw`, `tests::build::shape_builder_stroke_split_closes_open_paths_by_the_fill_rows_that_draw` and `tests::build::shape_builder_takes_the_live_paint_faces_and_edges_that_draw`. With In Merge Mode, Clicking Stroke Splits the Path on in the same dialog, a click on a stroke in merge mode splits its path into the run of stroke under the pointer and the rest of the path, as the [Shape Builder tool options](https://web.archive.org/web/20260105172244/https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html) page describes; the run goes from the anchor or crossing point before the pointer to the one after it, and the pointer shows scissors over it. Highlight Stroke When Editable draws that run in the color chosen in its Color list (Red by default), and with Pick Color From Color Swatches the run takes the current fill as its stroke (`shapeBuilder.merge` `splitStroke`). As VectorCraft's own rule, OK in Shape Builder Tool Options gives an error for a highlight Color with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. Regression test: `dialogs::shape_builder_options::tests::a_highlight_color_with_an_alpha_part_is_refused`. With Pick Color From Artwork, a merged shape takes the appearance, opacity and blending mode of the object where the drag starts, else of the object where it ends, else of the front-most selected object, as the [Shape Builder combine page](https://web.archive.org/web/20251006211643/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/combine-shapes-with-shape-builder-tool.html) describes. A click in an area that no selected object covers, such as a hole other shapes enclose, takes the front-most selected object's look (`shapeBuilder.merge` `points`: the first point is where the drag starts, and the last point is where it ends). From 0b1e1e99a11bd7ffebbcd755ce68db4a24055fa1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:22:45 -0800 Subject: [PATCH 52/82] =?UTF-8?q?Effects:=20Effect=20=E2=80=BA=20SVG=20Fil?= =?UTF-8?q?ters=20lists=20default=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effect › SVG Filters and Apply SVG Filter list 14 default filters in every document, before the document's own. The set, the names and the values are VectorCraft's own, written with SVG 1.1 filter primitives (crates/effects/src/svg_filter/defaults.rs). A document holds none of the defaults until one is applied or edited there. svgFilter.apply with a default's name copies it into the document in the same undo step, and svgFilter.define with replace naming a default adds the edited copy under that name. The copy is listed in the default's place. svgFilter.delete refuses a default the document holds no copy of, and deleting the copy lists the default again. svgFilter.list also returns the defaults. svgFilter.apply takes index and item to put the filter in the place of an applied SVG filter, which the dialog's OK uses when it is opened for an applied effect; an index that is not an applied SVG filter, null included, is an error. --- crates/effects/src/svg_filter.rs | 1 + crates/effects/src/svg_filter/defaults.rs | 248 ++++++++++++++++++++++ crates/engine/src/cmd/effectcmd.rs | 2 +- crates/engine/src/cmd/svgfilter.rs | 137 +++++++++--- crates/engine/src/tests/svgfilter.rs | 176 +++++++++++++++ crates/engine/tests/command_sweep.rs | 4 + crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/dialogs/svg_filter.rs | 229 +++++++++++++++----- crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/effects-parity.md | 2 +- docs/mcp.md | 20 +- docs/target-app-parity.md | 2 +- 12 files changed, 740 insertions(+), 85 deletions(-) create mode 100644 crates/effects/src/svg_filter/defaults.rs diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 3877a60ae9..0c4bff0f1d 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -16,6 +16,7 @@ use vectorcraft_geom::{Affine, Rect, Vec2}; use crate::pixel::PixelSpace; use crate::util::{num, text}; +pub mod defaults; #[cfg(test)] mod tests; diff --git a/crates/effects/src/svg_filter/defaults.rs b/crates/effects/src/svg_filter/defaults.rs new file mode 100644 index 0000000000..5ce67fac6b --- /dev/null +++ b/crates/effects/src/svg_filter/defaults.rs @@ -0,0 +1,248 @@ +//! The default SVG filters: the filters Effect › SVG Filters and Apply SVG Filter list in every +//! document. The set, the names and the values are VectorCraft's own, written with the filter +//! primitives of SVG 1.1 (). A default is not part of a +//! document until it is applied or edited there: the document then holds a copy under the +//! default's name, and that copy stands in the default's place in the lists. Every default computes +//! in sRGB (`color-interpolation-filters="sRGB"`), so art a filter passes through keeps its colors. + +use vectorcraft_doc::SvgFilter; + +/// A default SVG filter: its name (the `` element's `id`) and the element's XML. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DefaultFilter { + pub name: &'static str, + pub xml: &'static str, +} + +/// The default filters, in the order the lists show them. +pub const DEFAULT_FILTERS: [DefaultFilter; 14] = [ + DefaultFilter { + name: "Soft Blur", + xml: r#""#, + }, + DefaultFilter { + name: "Heavy Blur", + xml: r#""#, + }, + DefaultFilter { + name: "Soft Shadow", + xml: r#""#, + }, + DefaultFilter { + name: "Hard Shadow", + xml: r#""#, + }, + DefaultFilter { + name: "Glow", + xml: r##""##, + }, + DefaultFilter { + name: "Bevel", + xml: r##""##, + }, + DefaultFilter { + name: "Emboss", + xml: r#""#, + }, + DefaultFilter { + name: "Thicken", + xml: r#""#, + }, + DefaultFilter { + name: "Thin", + xml: r#""#, + }, + DefaultFilter { + name: "Rough Edges", + xml: r#""#, + }, + DefaultFilter { + name: "Grain", + xml: r#""#, + }, + DefaultFilter { + name: "Grayscale", + xml: r#""#, + }, + DefaultFilter { + name: "Invert", + xml: r#""#, + }, + DefaultFilter { + name: "Hue Shift", + xml: r#""#, + }, +]; + +/// The default filter named `name`. +pub fn default_filter(name: &str) -> Option { + DEFAULT_FILTERS.iter().copied().find(|f| f.name == name) +} + +impl DefaultFilter { + /// The filter as a document holds it once it is applied or edited there. + pub fn to_filter(self) -> SvgFilter { + SvgFilter { name: self.name.to_string(), xml: self.xml.to_string() } + } +} + +/// A filter of the lists ([`listed`]), and whether it is a default the document does not hold. +#[derive(Clone, Debug, PartialEq)] +pub struct Listed { + pub filter: SvgFilter, + /// A default the document has no filter named for: applying or editing it adds a copy. + pub default: bool, +} + +/// The filters Effect › SVG Filters and Apply SVG Filter list for a document that holds `held`, +/// and how many of them come first as the defaults: each default in [`DEFAULT_FILTERS`]' order (the +/// document's filter of that name in its place), then the document's other filters in their order. +pub fn listed(held: &[SvgFilter]) -> (Vec, usize) { + let mut out: Vec = DEFAULT_FILTERS + .iter() + .map(|d| match held.iter().find(|f| f.name == d.name) { + Some(f) => Listed { filter: f.clone(), default: false }, + None => Listed { filter: d.to_filter(), default: true }, + }) + .collect(); + let defaults = out.len(); + out.extend(held.iter().filter(|f| default_filter(&f.name).is_none()).map(|f| Listed { filter: f.clone(), default: false })); + (out, defaults) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_doc::Effect; + use vectorcraft_geom::{Affine, Point}; + + use super::*; + use crate::pixel::{Channels, PixelSpace}; + use crate::svg_filter::{SVG_FILTER, SvgFilterFx, check, with_id}; + use crate::{PixelFx, RasterFx, raster_effects}; + + /// A `w` × `w` raster whose pixels are one point wide. + fn space(w: usize) -> PixelSpace { + let wd = w as f64; + PixelSpace { + to_doc: Affine::scale(1.0), + px: 1.0, + center: Point::new(wd / 2.0, wd / 2.0), + channels: Channels::Rgb, + line: 1.0, + extent: 0.5 * wd.hypot(wd), + } + } + + /// The default named `name` as the renderer reads it. + fn fx(name: &str) -> SvgFilterFx { + let d = default_filter(name).unwrap_or_else(|| panic!("no default {name}")); + let e = Effect { id: SVG_FILTER.into(), params: json!({"name": d.name, "xml": d.xml}), visible: true }; + match raster_effects(&[e]).first() { + Some(RasterFx::Pixel(PixelFx::Svg(f))) => f.clone(), + other => panic!("{name}: {other:?}"), + } + } + + /// A 40 × 40 raster with an opaque square of `rgb` from 10 to 30, run through default `name`. + fn run(name: &str, rgb: [u8; 3]) -> Vec { + let mut data: Vec = (0..40usize) + .flat_map(|y| (0..40usize).map(move |x| (x, y))) + .flat_map(|(x, y)| if (10..30).contains(&x) && (10..30).contains(&y) { [rgb[0], rgb[1], rgb[2], 255] } else { [0; 4] }) + .collect(); + fx(name).apply(&mut data, 40, 40, &space(40)); + data + } + + fn at(d: &[u8], x: usize, y: usize) -> [u8; 4] { + let i = (y * 40 + x) * 4; + [d[i], d[i + 1], d[i + 2], d[i + 3]] + } + + #[test] + fn every_default_is_a_filter_the_check_takes_under_its_own_name() { + let mut seen = std::collections::BTreeSet::new(); + for d in DEFAULT_FILTERS { + assert_eq!(check(d.xml), Ok(d.name.to_string()), "{}", d.name); + assert!(seen.insert(d.name), "{} is listed twice", d.name); + assert_eq!(d.name, d.name.trim()); + // Edit SVG Filter keeps the name by writing it as the id: a default's code comes back as it is. + assert_eq!(with_id(d.xml, d.name).as_deref(), Some(d.xml), "{}", d.name); + assert_eq!(default_filter(d.name), Some(d)); + assert!(d.xml.contains(r#" color-interpolation-filters="sRGB""#), "{} computes in sRGB", d.name); + } + assert!(DEFAULT_FILTERS.len() <= SvgFilter::MAX); + assert_eq!(default_filter("soft blur"), None, "names match exactly"); + } + + #[test] + fn every_default_draws_and_is_written_to_svg_as_a_filter() { + let plain = { + let mut d = vec![0u8; 40 * 40 * 4]; + for y in 10..30 { + for x in 10..30 { + d[(y * 40 + x) * 4..(y * 40 + x) * 4 + 4].copy_from_slice(&[200, 60, 20, 255]); + } + } + d + }; + for d in DEFAULT_FILTERS { + assert_ne!(run(d.name, [200, 60, 20]), plain, "{} changes what is drawn", d.name); + assert!(fx(d.name).writes_as_svg11(), "{} is written as SVG 1.1", d.name); + } + } + + /// The color defaults against SVG 1.1's own arithmetic, in sRGB as each filter asks. + #[test] + fn the_color_defaults_compute_what_svg_1_1_defines() { + let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1); + // feComponentTransfer, type="table" with values 1 0: C' = 1 - C (§15.11). + let inv = at(&run("Invert", [200, 60, 20]), 20, 20); + assert!(near(inv, [55, 195, 235, 255]), "{inv:?}"); + // feColorMatrix type="saturate" values="0": every channel is 0.213 R + 0.715 G + 0.072 B (§15.10). + let grey = at(&run("Grayscale", [200, 60, 20]), 20, 20); + let l = (0.213 * 200.0 + 0.715 * 60.0 + 0.072 * 20.0f64).round() as u8; + assert!(near(grey, [l, l, l, 255]), "{grey:?} for {l}"); + // feColorMatrix type="hueRotate" values="180" (§15.10): cos = -1 and sin = 0 leave + // R' = (2·0.213 - 1) R + 2·0.715 G + 2·0.072 B, and likewise for G' and B'. + let hue = at(&run("Hue Shift", [200, 60, 20]), 20, 20); + let lum2 = 2.0 * (0.213 * 200.0 + 0.715 * 60.0 + 0.072 * 20.0f64); + let turned = [lum2 - 200.0, lum2 - 60.0, lum2 - 20.0].map(|v| v.round().clamp(0.0, 255.0) as u8); + assert!(near(hue, [turned[0], turned[1], turned[2], 255]), "{hue:?} for {turned:?}"); + } + + /// The shape defaults against what their primitives define: an offset copy of the alpha behind + /// the art (feOffset, §15.21), and a shape grown or shrunk by its radius (feMorphology, §15.20). + #[test] + fn the_shape_defaults_move_and_resize_the_alpha() { + let shadow = run("Hard Shadow", [200, 60, 20]); + assert_eq!(at(&shadow, 20, 20), [200, 60, 20, 255], "the art is on top"); + // Right of and below the art by up to 3: black at 60% alpha (premultiplied 153). + assert_eq!(at(&shadow, 31, 31), [0, 0, 0, 153]); + assert_eq!(at(&shadow, 34, 34)[3], 0, "past the offset"); + assert_eq!(at(&shadow, 8, 8)[3], 0, "nothing up and left"); + let thick = run("Thicken", [200, 60, 20]); + assert_eq!((at(&thick, 9, 20)[3], at(&thick, 7, 20)[3]), (255, 0), "grown by its radius"); + let thin = run("Thin", [200, 60, 20]); + assert_eq!((at(&thin, 10, 20)[3], at(&thin, 13, 20)[3]), (0, 255), "shrunk by its radius"); + let soft = run("Soft Shadow", [200, 60, 20]); + assert_eq!(at(&soft, 20, 20), [200, 60, 20, 255]); + let below = at(&soft, 33, 33); + assert!(below[3] > 0 && below[3] < 128 && below[0] == 0, "a blurred, half-strength shadow: {below:?}"); + } + + #[test] + fn the_lists_show_the_defaults_first_with_the_documents_copy_in_place() { + let (all, defaults) = listed(&[]); + assert_eq!((all.len(), defaults), (DEFAULT_FILTERS.len(), DEFAULT_FILTERS.len())); + assert!(all.iter().all(|l| l.default)); + let mine = SvgFilter { name: "Mine".into(), xml: "".into() }; + let glow = SvgFilter { name: "Glow".into(), xml: "".into() }; + let (all, defaults) = listed(&[mine.clone(), glow.clone()]); + assert_eq!((all.len(), defaults), (DEFAULT_FILTERS.len() + 1, DEFAULT_FILTERS.len())); + let at = DEFAULT_FILTERS.iter().position(|d| d.name == "Glow").unwrap(); + assert_eq!(all[at], Listed { filter: glow, default: false }, "the document's Glow where the default was"); + assert_eq!(all.last(), Some(&Listed { filter: mine, default: false })); + assert_eq!(all.iter().filter(|l| l.default).count(), DEFAULT_FILTERS.len() - 1); + } +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 65672f8b6b..974d876d2e 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -718,7 +718,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { /// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one /// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still /// fit; a hidden one has its given values checked. → `{ids}`. -fn set_params(s: &mut Session, p: &Value) -> Result { +pub(crate) fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; let mut params = p.get("params").cloned().unwrap_or(Value::Null); diff --git a/crates/engine/src/cmd/svgfilter.rs b/crates/engine/src/cmd/svgfilter.rs index 98b29a6e27..43af8bce35 100644 --- a/crates/engine/src/cmd/svgfilter.rs +++ b/crates/engine/src/cmd/svgfilter.rs @@ -3,9 +3,14 @@ //! applied effect names the document's filter: editing the filter changes what every object that //! uses it draws, and deleting it leaves their effects in place, drawing nothing, until a filter of //! that name exists again. Each effect holds the definition it draws with, kept in step here. +//! +//! The default filters ([`svg_filter::defaults`]) are listed for every document. Applying or +//! editing one adds a copy to the document under the default's name, and from then on that copy is +//! the filter of that name. Deleting the copy lists the default again. use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, Document, SvgFilter}; +use vectorcraft_render::effects::svg_filter::defaults::{DEFAULT_FILTERS, default_filter}; use vectorcraft_render::effects::{self, svg_filter}; use super::fileio::{absolute_path, read_file_head}; @@ -21,7 +26,7 @@ pub fn specs() -> Vec { "SVG Filters", [], None, - "{} → {filters: [{name, xml}…]} the SVG filters the document holds, in the SVG Filters submenu's order", + "{} → {filters: [{name, xml}…], defaults: [{name, xml, inDocument}…]} filters: the SVG filters the document holds, in the order they were added; defaults: the default filters every document lists (VectorCraft's own set), with inDocument true when the document holds a filter of that name, which then stands in the default's place. The SVG Filters submenu and Apply SVG Filter list the defaults first, then the document's other filters", has_doc, list ), @@ -30,7 +35,7 @@ pub fn specs() -> Vec { "New SVG Filter", [], None, - "{xml: one SVG element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter has is refused and a document holds at most 64 filters → {name, filters}", + "{xml: one SVG element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter of the document has is refused and a document holds at most 64 filters; replace with the name of a default filter the document does not hold adds the edited copy to the document under that name, and code equal to the default's adds nothing; a new filter may take a default's name, and then stands in its place → {name, filters}", has_doc, define ), @@ -39,7 +44,7 @@ pub fn specs() -> Vec { "Delete SVG Filter", [], None, - "{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again → {filters}", + "{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again, or the default of that name is applied; a default filter the document holds no copy of is refused (the defaults are not deleted), and deleting the document's copy of one lists the default again → {filters}", has_doc, delete ), @@ -57,7 +62,7 @@ pub fn specs() -> Vec { "Apply SVG Filter", [], None, - "{name, ids?|id?, item?} apply the document's SVG filter `name` (svgFilter.list) to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}", + "{name, ids?|id?, item?, index?} apply the document's SVG filter `name`, or the default filter of that name when the document holds none (svgFilter.list), to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}; with index, the filter takes the place of the applied svg.filter effect at index of item, like effect.setParams → {ids}; a default is copied into the document in the same undo step, which is refused when the document already holds 64 filters", has_doc, apply ), @@ -69,7 +74,10 @@ fn filters_json(d: &Document) -> Value { } fn list(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "filters": filters_json(&s.doc()?.doc) })) + let d = &s.doc()?.doc; + let held = |name: &str| d.svg_filters.iter().any(|f| f.name == name); + let defaults: Vec = DEFAULT_FILTERS.iter().map(|f| json!({ "name": f.name, "xml": f.xml, "inDocument": held(f.name) })).collect(); + Ok(json!({ "filters": filters_json(d), "defaults": defaults })) } /// `p`'s `name`, trimmed and not empty. @@ -132,10 +140,12 @@ fn define(s: &mut Session, p: &Value) -> Result { }; svg_filter::check(&xml).map_err(|e| bad(C, e))?; let filters = &s.doc()?.doc.svg_filters; - let at = match &replace { - Some(r) => Some(filters.iter().position(|f| f.name == *r).ok_or_else(|| bad(C, format!("the document has no SVG filter named {r:?}")))?), - None => None, - }; + let at = replace.as_ref().and_then(|r| filters.iter().position(|f| f.name == *r)); + // A default the document does not hold: its edited copy is added under the default's name. + let default = replace.as_ref().filter(|_| at.is_none()).and_then(|r| default_filter(r)); + if let Some(r) = replace.as_ref().filter(|_| at.is_none() && default.is_none()) { + return Err(bad(C, format!("the document has no SVG filter named {r:?}"))); + } // An edited filter keeps its name, whatever id its new code gives it. let xml = match &replace { Some(r) => svg_filter::with_id(&xml, r).ok_or_else(|| bad(C, "give one element"))?, @@ -145,22 +155,28 @@ fn define(s: &mut Session, p: &Value) -> Result { if filters.iter().enumerate().any(|(i, f)| f.name == name && Some(i) != at) { return Err(bad(C, format!("the document already has an SVG filter named {name:?}"))); } + let new = SvgFilter { name: name.clone(), xml }; + // Nothing to record when the filter, and every object that uses it, is already as given: a + // default saved with its own code stays out of the document. + let as_given = match default { + Some(d) => d.to_filter() == new, + None => at.and_then(|i| filters.get(i)) == Some(&new), + }; + if as_given && !out_of_step(&s.doc()?.doc, &new.name, &new.xml) { + return Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) })); + } if at.is_none() && filters.len() >= SvgFilter::MAX { return Err(bad(C, format!("a document holds at most {} SVG filters", SvgFilter::MAX))); } - let new = SvgFilter { name: name.clone(), xml }; - // Nothing to record when the filter, and every object that uses it, is already as given. - if at.and_then(|i| filters.get(i)) != Some(&new) || out_of_step(&s.doc()?.doc, &new.name, &new.xml) { - let label = if at.is_some() { "Edit SVG Filter" } else { "New SVG Filter" }; - s.edit(label, |d, _| { - update_users(d, &new.name, &new.xml); - match at.and_then(|i| d.svg_filters.get_mut(i)) { - Some(f) => *f = new, - None => d.svg_filters.push(new), - } - Ok(()) - })?; - } + let label = if replace.is_some() { "Edit SVG Filter" } else { "New SVG Filter" }; + s.edit(label, |d, _| { + update_users(d, &new.name, &new.xml); + match at.and_then(|i| d.svg_filters.get_mut(i)) { + Some(f) => *f = new, + None => d.svg_filters.push(new), + } + Ok(()) + })?; Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) })) } @@ -168,6 +184,9 @@ fn delete(s: &mut Session, p: &Value) -> Result { const C: &str = "svgFilter.delete"; let name = name_param(p, C)?; if !s.doc()?.doc.svg_filters.iter().any(|f| f.name == name) { + if default_filter(&name).is_some() { + return Err(bad(C, format!("{name:?} is a default SVG filter, and the document holds no copy of it to delete"))); + } return Err(bad(C, format!("the document has no SVG filter named {name:?}"))); } s.edit("Delete SVG Filter", |d, _| { @@ -222,13 +241,79 @@ fn import(s: &mut Session, p: &Value) -> Result { fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "svgFilter.apply"; let name = name_param(p, C)?; - let f = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name).cloned(); - let f = f.ok_or_else(|| bad(C, format!("the document has no SVG filter named {name:?}")))?; - let mut q = json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } }); + let filters = &s.doc()?.doc.svg_filters; + // The document's filter of that name, or the default of that name, which the document then takes. + let (f, copied) = match (filters.iter().find(|f| f.name == name), default_filter(&name)) { + (Some(f), _) => (f.clone(), false), + (None, Some(_)) if filters.len() >= SvgFilter::MAX => { + return Err(bad(C, format!("a document holds at most {} SVG filters, and applying a default filter adds it", SvgFilter::MAX))); + } + (None, Some(d)) => (d.to_filter(), true), + (None, None) => return Err(bad(C, format!("the document has no SVG filter named {name:?}"))), + }; + // A given `index`, null included, is checked as an index: only a missing one adds an effect. + let replaces = p.get("index").is_some(); + let mut q = match replaces { + true => json!({ "index": p["index"], "params": { "name": f.name, "xml": f.xml } }), + false => json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } }), + }; for key in ["ids", "id", "item"] { if let Some(v) = p.get(key) { q[key] = v.clone(); } } - super::effectcmd::apply(s, &q) + if !copied { + return apply_as_given(s, p, &q, replaces, None); + } + // The effect and the copy are two edits joined into one step. The history keeps room for both + // until they are joined, so the application lets one old step go at most, as any edit does. + let limit = s.doc()?.history.limit; + if let Some(st) = s.active_mut() { + st.history.limit = limit.saturating_add(2); + } + let out = apply_as_given(s, p, &q, replaces, Some(f)); + if let Some(st) = s.active_mut() { + st.history.limit = limit; + st.history.trim(); + } + out +} + +/// [`apply`] once the filter is known: the effect added (`q`) or put in the applied filter's place +/// (`replaces`), and `copy`, a default's copy, added to the document in the same undo step. +fn apply_as_given(s: &mut Session, p: &Value, q: &Value, replaces: bool, copy: Option) -> Result { + const C: &str = "svgFilter.apply"; + let newest = s.doc()?.history.step().0; + let out = if replaces { + super::appearance::index_param(p, "index", C)?; + // Only an applied SVG filter gives its place: another effect would take the filter's + // parameters as its own. + let edited = super::effectcmd::set_params_effects(s, q); + if edited.is_empty() || edited.iter().any(|e| e != effects::SVG_FILTER) { + return Err(bad(C, "`index` must be the index of an applied SVG filter")); + } + super::effectcmd::set_params(s, q)? + } else { + super::effectcmd::apply(s, q)? + }; + if let Some(f) = copy { + // The copy joins the step that applied it, under that step's name. That step is found by + // its document, not its place: in an undo group both edits join the group's one step. + let h = &s.doc()?.history; + let applied = h.undo.last().filter(|_| h.step().0 != newest).map(|step| (step.label.clone(), step.doc.clone())); + let label = applied.as_ref().map_or_else(|| "SVG Filter".to_string(), |(l, _)| l.clone()); + s.edit(&label, |d, _| { + update_users(d, &f.name, &f.xml); + d.svg_filters.push(f); + Ok(()) + })?; + let undo = &s.doc()?.history.undo; + let below = undo.len().checked_sub(2); + if let (Some(at), Some((_, doc))) = (below, &applied) + && undo.get(at).is_some_and(|e| std::sync::Arc::ptr_eq(&e.doc, doc)) + { + super::menucmds::squash(s, at, &label); + } + } + Ok(out) } diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index 10f0ad75ca..e42d7f9736 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -715,3 +715,179 @@ fn svg_export_draws_a_filter_svg_1_1_lacks_as_an_image() { assert_eq!((out.contains(" Vec<(String, bool)> { + let listed = run(s, "svgFilter.list", json!({}))["defaults"].as_array().cloned().unwrap_or_default(); + listed.iter().map(|f| (f["name"].as_str().unwrap().to_string(), f["inDocument"].as_bool().unwrap())).collect() +} + +/// Every document lists the default filters without holding them; applying one adds the effect and +/// the document's copy in one undo step. +#[test] +fn a_default_filter_is_listed_and_copied_into_the_document_when_applied() { + use vectorcraft_render::effects::svg_filter::defaults::DEFAULT_FILTERS; + let mut s = session(); + let listed = defaults(&mut s); + assert_eq!(listed.len(), DEFAULT_FILTERS.len()); + assert!(listed.iter().all(|(_, held)| !held), "a new document holds none of them"); + assert!(names(&mut s).is_empty()); + let soft = DEFAULT_FILTERS.iter().find(|f| f.name == "Soft Shadow").unwrap(); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["defaults"][2], json!({"name": "Soft Shadow", "xml": soft.xml, "inDocument": false})); + let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 50.0)); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let before = steps(&s); + let r = run(&mut s, "svgFilter.apply", json!({"name": "Soft Shadow"})); + assert_eq!((r["ids"].as_array().unwrap().len(), &r["index"]), (2, &json!(0))); + assert_eq!(steps(&s), before + 1, "the effect and the copy are one step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "SVG Filter"); + assert_eq!(names(&mut s), ["Soft Shadow"]); + assert_eq!(drawn(&s, a), json!(soft.xml)); + assert!(defaults(&mut s).contains(&("Soft Shadow".to_string(), true))); + // Applied again, the document's copy is the filter: nothing more is added. + run(&mut s, "svgFilter.apply", json!({"name": "Soft Shadow", "ids": [a.0]})); + assert_eq!(names(&mut s), ["Soft Shadow"]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(names(&mut s).is_empty(), "undo takes the copy back with the effect"); + assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty()); + run(&mut s, "edit.redo", json!({})); + assert_eq!((names(&mut s), drawn(&s, b)), (vec!["Soft Shadow".to_string()], json!(soft.xml))); + // Nothing to apply it to: no copy and no step. + let mut s = session(); + assert!(s.execute("svgFilter.apply", &json!({"name": "Glow"})).is_err(), "nothing selected"); + assert!(names(&mut s).is_empty()); + assert_eq!(steps(&s), 0); + assert!(err(&mut s, "svgFilter.apply", json!({"name": "glow", "ids": []})).contains("no SVG filter named \"glow\""), "names match exactly"); +} + +/// With a full history, applying a default lets one old step go, as any edit does: the copy joins +/// the step that applied it, and one Undo takes both back. +#[test] +fn a_default_applied_with_a_full_history_is_still_one_step() { + let mut s = session(); + s.active_mut().unwrap().history.limit = 3; + let a = rect(&mut s, 10.0); + rect(&mut s, 30.0); + rect(&mut s, 50.0); + assert_eq!(steps(&s), 3); + run(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0]})); + // The application takes one place in the history: only the oldest step went. + assert_eq!(steps(&s), 3); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "SVG Filter"); + run(&mut s, "edit.undo", json!({})); + assert!(names(&mut s).is_empty(), "the copy is undone with the effect"); + assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty()); +} + +/// Edit SVG Filter on a default saves the edited copy in the document under the default's name; +/// the document's filter then stands in the default's place, and deleting it lists the default again. +#[test] +fn an_edited_default_is_saved_in_the_document_and_delete_restores_the_default() { + use vectorcraft_render::effects::svg_filter::defaults::default_filter; + let mut s = session(); + let glow = default_filter("Glow").unwrap(); + // Saved with its own code: nothing to keep, and no undo step. + run(&mut s, "svgFilter.define", json!({"xml": glow.xml, "replace": "Glow"})); + assert_eq!((names(&mut s).len(), steps(&s)), (0, 0)); + let edited = r#""#; + assert_eq!(run(&mut s, "svgFilter.define", json!({"xml": edited, "replace": "Glow"}))["name"], "Glow"); + assert_eq!((names(&mut s), steps(&s)), (vec!["Glow".to_string()], 1)); + assert_eq!(s.doc().unwrap().history.undo[0].label, "Edit SVG Filter"); + let kept = r#""#; + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], kept, "it keeps the default's name"); + // Applying the name now applies the document's filter. + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [a.0]})); + assert_eq!(drawn(&s, a), json!(kept)); + // Deleting the copy leaves the effect drawing nothing, as for any filter, and lists the default. + run(&mut s, "svgFilter.delete", json!({"name": "Glow"})); + assert_eq!(drawn(&s, a), json!("")); + assert!(defaults(&mut s).contains(&("Glow".to_string(), false))); + // The default itself is not deleted. + let before = steps(&s); + assert!(err(&mut s, "svgFilter.delete", json!({"name": "Glow"})).contains("is a default SVG filter")); + assert!(err(&mut s, "svgFilter.delete", json!({"name": "Nope"})).contains("no SVG filter named")); + assert_eq!(steps(&s), before); + // Applied again, the default is copied back, and the object that named it draws with it. + let b = rect(&mut s, 50.0); + run(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [b.0]})); + assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(glow.xml), json!(glow.xml))); + assert_eq!(steps(&s), before + 2, "the rectangle and one step for the application"); + // A new filter may take a default's name the document does not hold. + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + assert!(defaults(&mut s).contains(&("Invert".to_string(), true))); + // An unknown name is still refused. + assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Nope"})).contains("no SVG filter named \"Nope\"")); +} + +/// With `index`, the filter takes the place of an applied SVG filter, a default with its copy in +/// the same undo step; any other effect keeps its place. +#[test] +fn apply_with_an_index_replaces_an_applied_filter() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "effect.apply", json!({"effect": "stylize.feather", "ids": [a.0]})); + let before = steps(&s); + let r = run(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0], "index": 0, "item": null})); + assert_eq!(r["ids"], json!([a.0])); + assert_eq!(steps(&s), before + 1, "one step"); + let fx = s.doc().unwrap().doc.node(a).unwrap().appearance.effects.clone(); + assert_eq!((fx.len(), fx[0].params["name"].as_str(), fx[1].id.as_str()), (2, Some("Invert"), "stylize.feather")); + assert_eq!(names(&mut s), ["Shift", "Invert"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Shift"]); + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects[0].params["name"], "Shift"); + // Not an SVG filter, no effect there, or not an index: refused, and the default is not copied. + let before = steps(&s); + for (index, why) in [ + (json!(1), "applied SVG filter"), + (json!(7), "applied SVG filter"), + (json!("x"), "`index`"), + (json!(-1), "`index`"), + (json!(null), "`index`"), + ] { + let e = err(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0], "index": index, "item": null})); + assert!(e.contains(why), "{index}: {e}"); + } + assert_eq!((names(&mut s), steps(&s)), (vec!["Shift".to_string()], before)); +} + +/// A default needs room in the document: at 64 filters applying one is refused, and a filter the +/// document holds still applies. +#[test] +fn a_full_document_refuses_a_default_it_does_not_hold() { + let mut s = session(); + let a = rect(&mut s, 10.0); + for i in 0..64 { + run(&mut s, "svgFilter.define", json!({"xml": format!("")})); + } + let before = steps(&s); + assert!(err(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [a.0]})).contains("at most 64 SVG filters")); + let edited = r#""#; + assert!(err(&mut s, "svgFilter.define", json!({"xml": edited, "replace": "Glow"})).contains("at most 64 SVG filters")); + assert_eq!(steps(&s), before); + assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty()); + run(&mut s, "svgFilter.apply", json!({"name": "f3", "ids": [a.0]})); +} + +/// A default applied in one document is written to SVG as a filter and read back from the native +/// format with the document's copy. +#[test] +fn an_applied_default_survives_svg_export_and_a_native_round_trip() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.apply", json!({"name": "Grayscale", "ids": [a.0]})); + let out = svg(&mut s); + assert!(out.contains("", "replace": "Glow"})), + ("svgFilter.delete", json!({"name": "Glow"})), ("svgFilter.delete", json!({"name": ["a"]})), ( "effect.apply", diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e801df4a7a..12c652061f 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1234,7 +1234,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.svgFilterDialog", "Apply SVG Filter…", "", - "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file", + "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's defaults (the document's filter of a default's name in its place) and then the document's other filters; OK runs svgFilter.apply with selected, and with index and item for the applied effect; New and Edit open the code, Save runs svgFilter.define (replace: the filter edited, a default included), Delete runs svgFilter.delete (off for a default the document holds no copy of) and Import… svgFilter.import with a picked SVG file", ), ( "ui.importSvgFilter", diff --git a/crates/ui-egui/src/dialogs/svg_filter.rs b/crates/ui-egui/src/dialogs/svg_filter.rs index af6849de43..f092a55cc4 100644 --- a/crates/ui-egui/src/dialogs/svg_filter.rs +++ b/crates/ui-egui/src/dialogs/svg_filter.rs @@ -1,16 +1,22 @@ -//! Apply SVG Filter (Effect › SVG Filters › Apply SVG Filter…): the document's SVG filters in a -//! list. OK applies the selected one to the selection as a live effect (`svgFilter.apply`), or, -//! opened from the Appearance panel for an applied SVG filter, puts it in that effect's place -//! (`effect.setParams`). New and Edit open the filter's code under the list; Save adds the filter -//! or replaces the one being edited (`svgFilter.define`). Delete removes the selected filter -//! (`svgFilter.delete`) and Import… adds the filters of an SVG file (`svgFilter.import`). Those -//! act at once. Effect › SVG Filters lists the same filters below its first items. +//! Apply SVG Filter (Effect › SVG Filters › Apply SVG Filter…): the default SVG filters and the +//! document's own in a list, the defaults first. OK applies the selected one to the selection as a +//! live effect, or, opened from the Appearance panel for an applied SVG filter, puts it in that +//! effect's place (`svgFilter.apply`, with `index`). New and Edit open the filter's code under the +//! list; Save adds the filter or replaces the one being edited (`svgFilter.define`). Delete +//! removes the selected filter (`svgFilter.delete`) and Import… adds the filters of an SVG file +//! (`svgFilter.import`). Those act at once. Effect › SVG Filters lists the same filters below its +//! first items. +//! +//! A default is not in the document until it is applied or edited: the document then holds a copy +//! under the default's name, which is listed in the default's place. Delete is off for a default +//! the document holds no copy of. //! //! Fields: `selected` (a filter's name), `code` (the code being edited), `editing` (`""`, `"new"` //! or the name of the filter being edited), and with an applied effect `__index`, `__item` and //! `missing` (the name of a deleted filter the effect names). use serde_json::{Value, json}; +use vectorcraft_effects::svg_filter::defaults::{DEFAULT_FILTERS, Listed, default_filter, listed}; use vectorcraft_engine::doc::SvgFilter; use super::DialogSpec; @@ -25,20 +31,21 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Apply SVG Filter…").trim_end_matches('…').to_string(), body, confirm, - ok_enabled: Some(|app, d| d.str("editing").is_empty() && filters(app).iter().any(|f| f.name == d.str("selected"))), + ok_enabled: Some(|app, d| d.str("editing").is_empty() && filters(app).0.iter().any(|f| f.filter.name == d.str("selected"))), min_width: 440.0, ..DialogSpec::FORM }; -/// The active document's SVG filters. -fn filters(app: &VectorcraftApp) -> Vec { - app.session.active().map(|st| st.doc.svg_filters.clone()).unwrap_or_default() +/// The filters the dialog lists for the active document, and how many come first as the defaults. +fn filters(app: &VectorcraftApp) -> (Vec, usize) { + listed(app.session.active().map(|st| st.doc.svg_filters.as_slice()).unwrap_or_default()) } /// `ui.svgFilterDialog {index?, item?}`: open Apply SVG Filter for the active document; with /// `index`, for the applied SVG filter at `index` of `item` (null: the object's effects): the -/// document's filter of its name selected; when the document has none of its name, the name shown -/// above the list if the filter was deleted, or else its code open as a new filter. +/// document's filter of its name selected, or the default of its name when the effect draws with +/// the default's code or with nothing; when neither has its name, the name shown above the list if +/// the filter was deleted, or else its code open as a new filter. pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { if app.session.active().is_none() { return Err("no document open".into()); @@ -53,17 +60,19 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let e = applied.get(k as usize).filter(|e| e.id == vectorcraft_effects::SVG_FILTER).ok_or_else(|| format!("no SVG filter at index {k}"))?; let name = e.params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); let xml = e.params.get("xml").and_then(Value::as_str).unwrap_or_default().to_string(); - let held = filters(app).into_iter().find(|f| f.name == name); - match held { - // The document's filter it names: selected. - Some(_) => fields["selected"] = json!(name), + let held = app.session.active().is_some_and(|st| st.doc.svg_filters.iter().any(|f| f.name == name)); + // A default the document holds no copy of, when the effect draws with its code or with nothing. + let default = default_filter(&name).is_some_and(|d| xml.is_empty() || xml == d.xml); + if held || default { + // The filter it names: selected. + fields["selected"] = json!(name); + } else if xml.is_empty() { // Deleted from the document: shown above the list, not selectable; OK waits for a choice. - None if xml.is_empty() => fields["missing"] = json!(name), + fields["missing"] = json!(name); + } else { // A filter the document doesn't hold (from an opened file): its code, to add it. - None => { - fields["code"] = json!(xml); - fields["editing"] = json!("new"); - } + fields["code"] = json!(xml); + fields["editing"] = json!("new"); } fields["__index"] = json!(k); // As given: `null` is the object's own effects, and none is the active item's. @@ -75,33 +84,39 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(Value::Null) } -/// OK: the selected filter to the selection, or in the applied effect's place. +/// OK: the selected filter to the selection, or in the applied effect's place. A default the +/// document holds no copy of is copied into it in the same undo step. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let name = d.str("selected"); - let f = filters(app).into_iter().find(|f| f.name == name).ok_or("select an SVG filter")?; - let r = match d.fields.get("__index").and_then(Value::as_u64) { - Some(k) => { - let mut p = json!({ "index": k, "params": { "name": f.name, "xml": f.xml } }); - if let Some(item) = d.fields.get("__item") { - p["item"] = item.clone(); - } - app.run("effect.setParams", p)? + let f = filters(app).0.into_iter().find(|f| f.filter.name == name).ok_or("select an SVG filter")?; + let mut p = json!({ "name": f.filter.name }); + if let Some(k) = d.fields.get("__index").and_then(Value::as_u64) { + p["index"] = json!(k); + if let Some(item) = d.fields.get("__item") { + p["item"] = item.clone(); } - None => app.run("svgFilter.apply", json!({ "name": f.name }))?, - }; + } + let r = app.run("svgFilter.apply", p)?; app.ui.dialog = None; Ok(r) } -/// The active document's filter names, mirrored for the (static) menu tree ([`sync`]). +/// The names of the active document's filters that are not a default's, mirrored for the (static) +/// menu tree ([`sync`]). fn names_store() -> &'static std::sync::RwLock> { static NAMES: std::sync::OnceLock>> = std::sync::OnceLock::new(); NAMES.get_or_init(Default::default) } -/// Mirror the active document's SVG filter names for the menu tree (cheap when unchanged). +/// The names of `held`, a document's filters, that are not a default's, in their order. +fn own_names(held: &[SvgFilter]) -> Vec { + held.iter().filter(|f| default_filter(&f.name).is_none()).map(|f| f.name.clone()).collect() +} + +/// Mirror the active document's SVG filter names for the menu tree (cheap when unchanged). A +/// filter with a default's name is the menu's entry of that name already. pub fn sync(app: &VectorcraftApp) { - let names: Vec = app.session.active().map(|st| st.doc.svg_filters.iter().map(|f| f.name.clone()).collect()).unwrap_or_default(); + let names: Vec = app.session.active().map(|st| own_names(&st.doc.svg_filters)).unwrap_or_default(); let same = names_store().read().map(|v| v.len() == names.len() && v.iter().zip(&names).all(|(a, b)| *a == b)).unwrap_or(true); if !same { if let Ok(mut v) = names_store().write() { @@ -111,20 +126,23 @@ pub fn sync(app: &VectorcraftApp) { } } -/// Effect › SVG Filters: Apply SVG Filter…, Import SVG Filter… and then the active document's -/// filters, each applying itself to the selection. +/// Effect › SVG Filters: Apply SVG Filter…, Import SVG Filter…, the default filters and then the +/// active document's other filters, each applying itself to the selection. pub fn menu_items() -> Vec { items_for(names_store().read().map(|v| v.clone()).unwrap_or_default()) } -/// [`menu_items`] for filters named `names`. +/// [`menu_items`] for a document whose filters that are not a default's are named `names`. fn items_for(names: Vec<&'static str>) -> Vec { use crate::menus::Item; + let apply = |n: &'static str| Item::Cmd(n, "svgFilter.apply", json!({ "name": n })); let mut v = vec![Item::Cmd("Apply SVG Filter…", "ui.svgFilterDialog", Value::Null), Item::Cmd("Import SVG Filter…", "ui.importSvgFilter", Value::Null)]; + v.push(Item::Sep); + v.extend(DEFAULT_FILTERS.iter().map(|d| apply(d.name))); if !names.is_empty() { v.push(Item::Sep); - v.extend(names.into_iter().map(|n| Item::Cmd(n, "svgFilter.apply", json!({ "name": n })))); + v.extend(names.into_iter().map(apply)); } v } @@ -170,7 +188,8 @@ enum Action { } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let list = filters(app); + let (list, defaults) = filters(app); + let held = list.iter().filter(|f| !f.default).count(); let selected = d.str("selected"); let editing = d.str("editing"); let mut act = None; @@ -184,7 +203,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if !missing.is_empty() { ui.add_enabled_ui(false, |ui| ui.selectable_label(false, missing.as_str())); } - for f in &list { + for (i, Listed { filter: f, .. }) in list.iter().enumerate() { + // The document's other filters follow the defaults, below a line. + if i == defaults && i > 0 { + ui.separator(); + } let r = ui.selectable_label(f.name == selected, f.name.as_str()); if r.double_clicked() && editing.is_empty() { act = Some(Action::Edit(f.name.clone())); @@ -195,17 +218,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); }); ui.add_space(6.0); - let has = list.iter().any(|f| f.name == selected); + let chosen = list.iter().find(|f| f.filter.name == selected); + let has = chosen.is_some(); + // A default the document holds no copy of has nothing to delete. + let deletable = chosen.is_some_and(|f| !f.default); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let idle = editing.is_empty(); - if ui.add_enabled_ui(idle && list.len() < SvgFilter::MAX, |ui| widgets::flat_button(ui, tl!("New"), 64.0)).inner.clicked() { + if ui.add_enabled_ui(idle && held < SvgFilter::MAX, |ui| widgets::flat_button(ui, tl!("New"), 64.0)).inner.clicked() { act = Some(Action::New); } if ui.add_enabled_ui(idle && has, |ui| widgets::flat_button(ui, tl!("Edit"), 64.0)).inner.clicked() { act = Some(Action::Edit(selected.clone())); } - if ui.add_enabled_ui(idle && has, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() { + if ui.add_enabled_ui(idle && deletable, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() { act = Some(Action::Delete); } if crate::picks::can(app, &PickRequest::Open(pick())) @@ -245,7 +271,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { Ok(()) } Some(Action::Edit(name)) => { - if let Some(f) = list.iter().find(|f| f.name == name) { + if let Some(f) = list.iter().map(|f| &f.filter).find(|f| f.name == name) { d.fields.insert("selected".into(), json!(f.name)); d.fields.insert("editing".into(), json!(f.name)); d.fields.insert("code".into(), json!(f.xml)); @@ -322,7 +348,9 @@ mod tests { crate::menus::invoke(&mut app, "ui.svgFilterDialog", json!({})); let mut d = app.ui.dialog.clone().unwrap(); assert_eq!(d.kind, KIND); - assert!(frame(&mut app, &mut d).iter().any(|t| t == "Shift"), "the filter's name, as it is"); + // Below the defaults, which fill the rows the list shows at first. + assert_eq!(filters(&app).0.last().map(|f| f.filter.name.as_str()), Some("Shift"), "the filter's name, as it is"); + assert!(!frame(&mut app, &mut d).is_empty()); assert!(!(SPEC.ok_enabled.unwrap())(&app, &d), "nothing selected"); d.fields.insert("selected".into(), json!("Shift")); assert!((SPEC.ok_enabled.unwrap())(&app, &d)); @@ -411,11 +439,30 @@ mod tests { use crate::menus::Item; let ids = |items: &[Item]| items.iter().map(|i| if let Item::Cmd(l, c, p) = i { format!("{l} {c} {p}") } else { "—".into() }).collect::>(); - assert_eq!(ids(&items_for(vec![])), ["Apply SVG Filter… ui.svgFilterDialog null", "Import SVG Filter… ui.importSvgFilter null"]); + // The defaults follow the commands in every document, and with no document. + let plain = ids(&items_for(vec![])); assert_eq!( - ids(&items_for(vec!["Shift", "A (1)"]))[2..], - ["—", "Shift svgFilter.apply {\"name\":\"Shift\"}", "A (1) svgFilter.apply {\"name\":\"A (1)\"}"] + plain[..4], + [ + "Apply SVG Filter… ui.svgFilterDialog null", + "Import SVG Filter… ui.importSvgFilter null", + "—", + "Soft Blur svgFilter.apply {\"name\":\"Soft Blur\"}" + ] ); + assert_eq!(plain.len(), 3 + DEFAULT_FILTERS.len()); + assert_eq!(plain.last().map(String::as_str), Some("Hue Shift svgFilter.apply {\"name\":\"Hue Shift\"}")); + // The document's other filters come below them. + let with = ids(&items_for(vec!["Shift", "A (1)"])); + assert_eq!(with[..plain.len()], plain[..]); + assert_eq!(with[plain.len()..], ["—", "Shift svgFilter.apply {\"name\":\"Shift\"}", "A (1) svgFilter.apply {\"name\":\"A (1)\"}"]); + // A document's filter with a default's name is that default's entry. + let held = [ + SvgFilter { name: "Glow".into(), xml: String::new() }, + SvgFilter { name: "Shift".into(), xml: String::new() }, + SvgFilter { name: "glow".into(), xml: String::new() }, + ]; + assert_eq!(own_names(&held), ["Shift", "glow"]); let mut app = app(); assert!(!crate::menus::enabled(&app, "svgFilter.apply"), "nothing selected"); app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); @@ -423,6 +470,88 @@ mod tests { assert_eq!(crate::menus::display_label(&app, "svgFilter.apply", "Shift"), "Shift", "a filter's name as it is"); } + /// The steps in the active document's undo history. + fn steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() + } + + #[test] + fn a_default_is_listed_first_and_ok_copies_it_into_the_document() { + let mut app = app(); + app.session.execute("svgFilter.define", &json!({"xml": SHIFT})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + open(&mut app, &json!({})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + let (list, defaults) = filters(&app); + let names: Vec<&str> = list.iter().map(|f| f.filter.name.as_str()).collect(); + assert_eq!((defaults, names.len()), (DEFAULT_FILTERS.len(), DEFAULT_FILTERS.len() + 1)); + assert_eq!((names[0], names[defaults - 1], names[defaults]), ("Soft Blur", "Hue Shift", "Shift"), "the defaults, then the document's own"); + assert!(frame(&mut app, &mut d).iter().any(|t| t == "Soft Blur"), "a default's name, as it is"); + d.fields.insert("selected".into(), json!("Soft Shadow")); + assert!((SPEC.ok_enabled.unwrap())(&app, &d), "a default can be applied"); + let before = steps(&app); + confirm(&mut app, &d).unwrap(); + assert_eq!(steps(&app), before + 1, "one undo step"); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.svg_filters.iter().map(|f| f.name.as_str()).collect::>(), ["Shift", "Soft Shadow"]); + let fx = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects; + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Soft Shadow"))); + assert_eq!(fx[0].params["xml"].as_str(), default_filter("Soft Shadow").map(|f| f.xml)); + // Opened for that effect, the document's copy is selected; OK with another default puts it + // in the effect's place and copies it, in one step. + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.str("selected").as_str(), d.str("editing").as_str()), ("Soft Shadow", "")); + d.fields.insert("selected".into(), json!("Invert")); + let before = steps(&app); + confirm(&mut app, &d).unwrap(); + assert_eq!(steps(&app), before + 1); + let doc = &app.session.active().unwrap().doc; + let fx = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects; + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Invert")), "in its place, not added"); + assert_eq!(doc.svg_filters.len(), 3); + } + + /// Edit on a default opens its code, and Save keeps the edited copy in the document under the + /// default's name; an effect that names a default the document no longer holds selects it. + #[test] + fn an_edited_default_is_saved_in_the_document() { + let mut app = app(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + // Save, as its button does for the default being edited. + let edited = r#""#; + let r = app.run("svgFilter.define", json!({"xml": edited, "replace": "Thin"})).unwrap(); + assert_eq!(r["name"], "Thin"); + assert_eq!(filters(&app).0.iter().filter(|f| !f.default).map(|f| f.filter.xml.as_str()).collect::>(), [edited]); + assert_eq!(filters(&app).0.len(), DEFAULT_FILTERS.len(), "in the default's place"); + app.session.execute("svgFilter.apply", &json!({"name": "Thin"})).unwrap(); + // Deleted: the default is listed again, and the effect that names it selects the default. + app.session.execute("svgFilter.delete", &json!({"name": "Thin"})).unwrap(); + assert!(filters(&app).0.iter().all(|f| f.default)); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.str("selected").as_str(), d.str("missing").as_str(), d.str("editing").as_str()), ("Thin", "", "")); + confirm(&mut app, &d).unwrap(); + let doc = &app.session.active().unwrap().doc; + let fx = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects; + assert_eq!(fx[0].params["xml"].as_str(), default_filter("Thin").map(|f| f.xml), "the default again"); + // An applied filter with a default's name and other code opens as code to save. + let e = vectorcraft_doc::Effect { + id: "svg.filter".into(), + params: json!({"name": "Glow", "xml": ""}), + visible: true, + }; + app.session + .edit("Plant", |d, _| { + d.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects = vec![e]; + Ok(()) + }) + .unwrap(); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.str("editing").as_str(), d.str("selected").as_str()), ("new", "")); + } + #[test] fn new_saves_the_code_and_selects_the_filter() { let mut app = app(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index d43d17220c..57a08e8ce0 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1235,7 +1235,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.svgFilterDialog", "Apply SVG Filter…", "", - "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file", + "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's defaults (the document's filter of a default's name in its place) and then the document's other filters; OK runs svgFilter.apply with selected, and with index and item for the applied effect; New and Edit open the code, Save runs svgFilter.define (replace: the filter edited, a default included), Delete runs svgFilter.delete (off for a default the document holds no copy of) and Import… svgFilter.import with a picked SVG file", ), ( "ui.importSvgFilter", diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 1457e35bc6..342a5171b6 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -27,7 +27,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | -| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | +| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists 14 default filters and then the document's own; the public page names no default set, so the set, names and values are VectorCraft's own, and a default is copied into the document when it is applied or edited. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..e77c63da33 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5172,8 +5172,19 @@ it. The native format keeps the events and the files. ## SVG filters -Effect › SVG Filters lists the document's SVG filters below Apply SVG Filter… and Import SVG Filter…; choosing one -applies it to the selection. `svgFilter.list {}` returns them as `filters: [{name, xml}]`. Each is one `` +Effect › SVG Filters lists the default SVG filters and then the document's other filters below Apply SVG Filter… and +Import SVG Filter…; choosing one applies it to the selection +([apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html)). +The 14 defaults are VectorCraft's own set, written with [SVG 1.1 filter primitives](https://www.w3.org/TR/SVG11/filters.html): +Soft Blur, Heavy Blur, Soft Shadow, Hard Shadow, Glow, Bevel, Emboss, Thicken, Thin, Rough Edges, Grain, Grayscale, +Invert and Hue Shift. Every document lists them, and a document holds none of them until one is applied or edited +there. `svgFilter.apply` with a default's name copies the default into the document in the same undo step (refused +when the document already holds 64 filters), and `svgFilter.define` with `replace` naming a default adds the edited +copy under that name (code equal to the default's adds nothing). The document's filter of a default's name is then +listed in the default's place. `svgFilter.delete` refuses a default the document holds no copy of; deleting the copy +lists the default again, and applying the default again makes the effects that still name it draw with it. +`svgFilter.list {}` returns the document's filters as `filters: [{name, xml}]` and the defaults as +`defaults: [{name, xml, inDocument}]`. Each filter is one `` element whose `id` is its name, with 1 to 64 primitives and at most 64 KB of XML (`feTurbulence` octaves up to 10, `feConvolveMatrix` kernels up to 9 cells a side, `feMorphology` radii and `kernelUnitLength` up to 100 and `stdDeviation` up to 500 user units, elements nested at most 16 deep). It holds only filter primitives, @@ -5184,8 +5195,9 @@ a filter after the others, or with `replace` replaces the filter of that name, w filter has is refused, and the same filter again records no undo step. `svgFilter.delete {name}` removes one, and `svgFilter.import {path?|svg?}` adds the `` elements of an SVG file (at most 16 MB, not SVGZ, the first 256 filters) or of SVG text, leaving out the names the document already has and the filters `svgFilter.define` would refuse (`{added, skipped}`). Each change is one undo -step. `svgFilter.apply {name, ids?|id?, item?}` applies one as the live effect `svg.filter`, as `effect.apply` does, -in one undo step. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes +step. `svgFilter.apply {name, ids?|id?, item?, index?}` applies one as the live effect `svg.filter`, as `effect.apply` does, +in one undo step; with `index` it puts the filter in the place of the applied `svg.filter` effect at that index of +`item`, as `effect.setParams` does, and an index that is not an applied SVG filter is an error. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes what every object that uses it draws, in the edit's one undo step, and an application after an edit takes the edited filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml` empty) until a filter of that name is defined or imported again. A pasted effect that names one of the document's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..ecfafece5c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1920,7 +1920,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests/pdfraster.rs`). - **Rasterize effect:** Effect › Rasterize… (`rasterize`) draws an object as an image of square pixels at its own resolution or the document's raster effects resolution, with Object › Rasterize's color model, background, anti-aliasing, clipping mask and room around the object, while its paths stay editable. Expand Appearance and PDF and SVG export of an object whose only raster effect is Rasterize give the image Object › Rasterize gives (`crates/engine/src/tests/rastersettings.rs`, `crates/render/src/tests/rasterize.rs`). Behavior reference: [How to save artwork in Illustrator](https://helpx.adobe.com/illustrator/using/saving-artwork.html), [Work with effects](https://helpx.adobe.com/illustrator/using/effects.html). - **Rasterize colour models:** Object › Rasterize with the Grayscale colour model makes a grayscale image (a gray PNG, with alpha over a transparent background), and with Bitmap a 1-bit image (a TIFF), or a black-and-white gray PNG with alpha over a transparent background; `image.info` and the Control bar say Grayscale, and a fill color colorizes the image, in RGB and CMYK documents (#6969). Expand Appearance and PDF export of a Rasterize effect make the same images. Regression coverage: `rasterize_to_grayscale_or_bitmap_makes_a_grayscale_image` (`crates/engine/src/tests/rastersettings.rs`) and `gray_and_bilevel_images_store_gray` (`crates/render/src/encode/tests.rs`). Scores and estimates unchanged. -- **SVG filters:** Effect › SVG Filters lists the document's own SVG filters below Apply SVG Filter… and Import SVG Filter…, and choosing one applies it to the selection as a live effect in one undo step (`svgFilter.apply`). Apply SVG Filter applies the selected filter, New and Edit open its code, and Delete and Import… remove filters or add those of an SVG file (`svgFilter.define`, `svgFilter.delete`, `svgFilter.import`). VectorCraft ships no default filters. The canvas, raster export and PDF draw the filter at the Document Raster Effects resolution; SVG export writes it as a `` element when it is the object's last effect and otherwise draws the object as an image; SVG import keeps filters the other live effects can't express as this effect instead of leaving them out; the native format keeps the filters (`crates/engine/src/tests/svgfilter.rs`, `crates/effects/src/svg_filter/tests.rs`). Behavior reference: [Apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html), [CS5 help, Apply SVG effects](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=402), [SVG 1.1 Filter Effects](https://www.w3.org/TR/SVG11/filters.html). +- **SVG filters:** Effect › SVG Filters lists 14 default SVG filters and then the document's own below Apply SVG Filter… and Import SVG Filter…, and choosing one applies it to the selection as a live effect in one undo step (`svgFilter.apply`). Apply SVG Filter applies the selected filter, New and Edit open its code, and Delete and Import… remove filters or add those of an SVG file (`svgFilter.define`, `svgFilter.delete`, `svgFilter.import`). The public page names no default set: the defaults (Soft Blur to Hue Shift, `crates/effects/src/svg_filter/defaults.rs`) are VectorCraft's own, written with SVG 1.1 primitives. A default is copied into the document when it is applied or edited, and Delete does not remove a default. The canvas, raster export and PDF draw the filter at the Document Raster Effects resolution; SVG export writes it as a `` element when it is the object's last effect and otherwise draws the object as an image; SVG import keeps filters the other live effects can't express as this effect instead of leaving them out; the native format keeps the filters (`crates/engine/src/tests/svgfilter.rs`, `crates/effects/src/svg_filter/tests.rs`). Behavior reference: [Apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html), [CS5 help, Apply SVG effects](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=402), [SVG 1.1 Filter Effects](https://www.w3.org/TR/SVG11/filters.html). - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. From 8ea413f054d3b37d004bf8156fd13271ffa1844e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:19:42 -0800 Subject: [PATCH 53/82] SVG import: measure what an SVG expands to before the style cascade reads it The depth check and the prepass read each element's declarations through a cascade that keeps a copy of them per element, so one 100 kB style sheet rule on 16,000 groups still took 1.5 GB before the SVG was refused. The SVG and each SVG image it shows are now measured before that cascade runs. Copies nested past the depth cap, and loops of references, are found as too deep: an SVG is refused with the depth error, and an SVG image is left out with the depth warning, as before. --- crates/svg/src/import.rs | 9 +++-- crates/svg/src/import/budget.rs | 62 +++++++++++++++++++++++++++------ crates/svg/src/import/css.rs | 2 +- crates/svg/src/import/files.rs | 21 ++++++----- 4 files changed, 71 insertions(+), 23 deletions(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index b583f3dc03..c17f689922 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -137,10 +137,14 @@ pub(crate) fn import_finding( return Err(too_deep()); } let xml = parse(svg)?; + // Before the style cascade of `depth::height` and the prepass, which keeps the declarations of + // each element it reads; measured again below on the text usvg reads when that differs. + let styles = budget::check(&xml, budget::MAX_COPIED_STYLES, depth::MAX_ELEMENT_DEPTH)?; let Some(height) = depth::height(&xml, depth::MAX_ELEMENT_DEPTH) else { return Err(too_deep()); }; let (svg, found) = prepass(svg, &scan.markers, &xml, opts)?; + let rewritten = matches!(svg, Cow::Owned(_)); // The prepass's edits are in the text: the XML is read again only when it made any. let xml = match &svg { Cow::Borrowed(_) => xml, @@ -157,8 +161,9 @@ pub(crate) fn import_finding( let (src, slots) = text::prepare(svg, &xml, text_xml.as_ref(), units.dpi, &mut warnings); // Before usvg copies what ``s show and the style values of each copy: the text usvg reads. let styles = match &src { - Cow::Borrowed(_) => budget::check(&xml, budget::MAX_COPIED_STYLES)?, - Cow::Owned(prepared) => budget::check(&parse(prepared)?, budget::MAX_COPIED_STYLES)?, + Cow::Borrowed(_) if !rewritten => styles, + Cow::Borrowed(_) => budget::check(&xml, budget::MAX_COPIED_STYLES, usize::MAX)?, + Cow::Owned(prepared) => budget::check(&parse(prepared)?, budget::MAX_COPIED_STYLES, usize::MAX)?, }; let files = found.files; files.copied_styles(styles); diff --git a/crates/svg/src/import/budget.rs b/crates/svg/src/import/budget.rs index c2ff01a02f..ec4de17eca 100644 --- a/crates/svg/src/import/budget.rs +++ b/crates/svg/src/import/budget.rs @@ -27,7 +27,7 @@ use std::collections::{HashMap, HashSet}; use usvg::roxmltree; use super::{MAX_NODES, SVG_NS, XNode}; -use crate::SvgError; +use crate::{MAX_ELEMENT_DEPTH, SvgError}; /// Most elements usvg may make of an SVG: [`MAX_NODES`], and below the 1,000,000 elements past /// which usvg stops (its count includes a root of its own). @@ -287,12 +287,14 @@ impl<'a, 'i> Measure<'a, 'i> { } /// The bytes of style values usvg would copy into the elements of `xml`, when that is no more -/// than `bytes_left` and it makes no more than [`MAX_SVG_ELEMENTS`] elements of it. -pub(super) fn check(xml: &roxmltree::Document, bytes_left: u64) -> Result { - match measure(xml, MAX_SVG_ELEMENTS as u64, bytes_left) { +/// than `bytes_left` and it makes no more than [`MAX_SVG_ELEMENTS`] elements of it, nested no +/// deeper than `depth` (counted as `depth::height` counts, which finds at least as much). +pub(super) fn check(xml: &roxmltree::Document, bytes_left: u64, depth: usize) -> Result { + match measure(xml, MAX_SVG_ELEMENTS as u64, bytes_left, depth) { Ok(cost) => Ok(cost.bytes), Err(Past::Elements) => Err(SvgError::TooLarge(MAX_SVG_ELEMENTS)), Err(Past::Bytes) => Err(SvgError::StylesTooLarge((MAX_COPIED_STYLES >> 20) as usize)), + Err(Past::Depth) => Err(SvgError::TooDeep(MAX_ELEMENT_DEPTH)), } } @@ -301,11 +303,13 @@ pub(super) fn check(xml: &roxmltree::Document, bytes_left: u64) -> Result Result { +/// What usvg makes of `xml`, up to `elements` elements and `bytes` bytes of copied styles, its +/// elements and the copies ``s show nested up to `depth` deep. Without recursion; each +/// element is measured once. +fn measure(xml: &roxmltree::Document, elements: u64, bytes: u64, depth: usize) -> Result { let mut m = Measure::new(xml); let mut costs: HashMap = HashMap::new(); // The elements entered and not yet left: one entered again is a loop. @@ -326,7 +330,11 @@ fn measure(xml: &roxmltree::Document, elements: u64, bytes: u64) -> Result depth { + return Err(Past::Depth); } let (parts, bare) = match svg_name(n) { Some("text") => (vec![], None), @@ -376,12 +384,12 @@ mod tests { /// What [`measure`] makes of `text` with no limits, as (elements, bytes). fn cost(text: &str) -> (u64, u64) { let xml = roxmltree::Document::parse(text).unwrap(); - let c = measure(&xml, u64::MAX, u64::MAX).unwrap_or_else(|p| panic!("{p:?}")); + let c = measure(&xml, u64::MAX, u64::MAX, usize::MAX).unwrap_or_else(|p| panic!("{p:?}")); (c.elements, c.bytes) } fn past(text: &str, elements: u64, bytes: u64) -> Option { - measure(&roxmltree::Document::parse(text).unwrap(), elements, bytes).err() + measure(&roxmltree::Document::parse(text).unwrap(), elements, bytes, usize::MAX).err() } /// Groups `g0` to `g{levels}`: `g0` a rectangle styled `style`, each other two ``s of the @@ -445,13 +453,30 @@ mod tests { #[test] fn a_loop_usvg_copies_without_end_is_refused_as_usvg_refuses_it() { let looped = svg(""); - assert_eq!(past(&looped, u64::MAX, u64::MAX), Some(Past::Elements)); + assert_eq!(past(&looped, u64::MAX, u64::MAX), Some(Past::Depth)); assert!(usvg::Tree::from_str(&looped, &usvg::Options::default()).is_err()); // Without the loop, usvg reads it. let open = svg(""); assert!(past(&open, u64::MAX, u64::MAX).is_none() && usvg::Tree::from_str(&open, &usvg::Options::default()).is_ok()); } + /// A chain of ``s nested past the depth is too deep, as `depth::height` finds it, before + /// what the chain makes is added up; one within the depth `depth::height` gives is not. + #[test] + fn copies_nested_past_the_depth_are_too_deep() { + let links: String = (0..3000).map(|i| format!("", i + 1)).collect(); + let deep = svg(&format!("{links}")); + let xml = roxmltree::Document::parse(&deep).unwrap(); + assert_eq!(measure(&xml, u64::MAX, u64::MAX, MAX_ELEMENT_DEPTH).err(), Some(Past::Depth)); + assert!(super::super::depth::height(&xml, MAX_ELEMENT_DEPTH).is_none()); + assert!(matches!(check(&xml, MAX_COPIED_STYLES, MAX_ELEMENT_DEPTH), Err(SvgError::TooDeep(_)))); + for text in [chain(5, ""), svg("")] { + let xml = roxmltree::Document::parse(&text).unwrap(); + let height = super::super::depth::height(&xml, usize::MAX).unwrap_or(0); + assert!(measure(&xml, u64::MAX, u64::MAX, height).is_ok(), "{height} {text}"); + } + } + #[test] fn text_counts_its_runs_and_what_trefs_show() { let (n, bytes) = cost(&svg("abcdxyz")); @@ -459,4 +484,19 @@ mod tests { assert_eq!(n, 1 + 1 + 1 + 2 + 1 + 1 + 1); assert_eq!(bytes, (16 + 2) + (16 + 1) + (16 + 4)); } + + /// A long style sheet rule that matches many elements is refused before the import's own style + /// cascade (which keeps the declarations of each element) reads them, in an SVG and in an SVG + /// image it shows. + #[test] + fn a_long_rule_on_many_elements_is_refused_before_the_cascade_reads_it() { + let groups = svg(&format!("{}", "a".repeat(100_000), "".repeat(1000))); + let image = svg(&format!(r#""#, crate::base64_encode(groups.as_bytes()))); + for text in [groups, image] { + let before = super::super::css::selector_tests(); + let err = super::super::import(&text, &crate::ImportOptions::default(), MAX_NODES).err(); + assert!(matches!(err, Some(SvgError::StylesTooLarge(64))), "{err:?}"); + assert_eq!(super::super::css::selector_tests(), before); + } + } } diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index fd864a547e..7ddfb1bbd5 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -129,7 +129,7 @@ thread_local! { /// [`SELECTOR_TESTS`] of this thread. #[cfg(test)] -fn selector_tests() -> u64 { +pub(super) fn selector_tests() -> u64 { SELECTOR_TESTS.get() } diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index 429c39c70f..98c0e4aed4 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -587,22 +587,25 @@ impl Files { return leave_out(&self.too_deep, true); } let xml = super::parse(text).ok()?; - let Some(height) = depth::height(&xml, left) else { return leave_out(&self.too_deep, true) }; - // usvg's parser would overflow on it, as on the SVG holding it (`import`). - super::convolve_matrices_ok(&xml).ok()?; - // What usvg would make of it, measured as the SVG holding it is (`import`), its copied - // styles counted with theirs while usvg reads it, and kept only when it decodes: past the - // limits, the whole SVG is refused. + // What usvg would make of it, measured as the SVG holding it is (`import`), before the + // style cascade of `depth::height`; its copied styles count with theirs while usvg reads + // it, and only when it decodes. Too deep, it is left out; past the other limits, the whole + // SVG is refused. let spent = self.styles.load(Ordering::Relaxed); - match budget::check(&xml, budget::MAX_COPIED_STYLES.saturating_sub(spent)) { - Ok(bytes) => self.styles.store(spent.saturating_add(bytes), Ordering::Relaxed), + let bytes = match budget::check(&xml, budget::MAX_COPIED_STYLES.saturating_sub(spent), left) { + Ok(bytes) => bytes, + Err(SvgError::TooDeep(_)) => return leave_out(&self.too_deep, true), Err(e) => { if let Ok(mut refused) = self.refused.lock() { refused.get_or_insert(e); } return None; } - } + }; + let Some(height) = depth::height(&xml, left) else { return leave_out(&self.too_deep, true) }; + // usvg's parser would overflow on it, as on the SVG holding it (`import`). + super::convolve_matrices_ok(&xml).ok()?; + self.styles.store(spent.saturating_add(bytes), Ordering::Relaxed); self.depth.store(used + height, Ordering::Relaxed); let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some(); // usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver. From a067279ee9fca3a969be1b33e8c231b5c5693ed9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:31:49 -0800 Subject: [PATCH 54/82] Docs: legacy .ai Insert Aki warning applies only to type written as text With outlineText the legacy .ai save writes the type as outlines, which keep the Insert Aki spacing, and does not warn that the values are left out. --- docs/mcp.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 1d487c511e..60328627e6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8815,8 +8815,9 @@ justification keeps that width. Live SVG text places each character the space mo Insert Aki is written as outlines. An SVG saved without Preserve Editing (`preserveEditing`) reopens without the Insert Aki values; save it with Preserve Editing to keep them editable. An `.ai` file's text document keeps both values, and opening the file reads them back. The stored values are read in thousandths of an em and a negative -value opens as 0; these are VectorCraft's own rules. A legacy `.ai` save writes type with Insert Aki as text without the values and warns that they are left -out. +value opens as 0; these are VectorCraft's own rules. When a legacy `.ai` save writes type as text, it writes type with +Insert Aki without the values and warns that they are left out. With `outlineText: true` it writes the type as outlines, +which keep the space Insert Aki adds, and does not warn that the values are left out. ```json {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"akiRight":500}}} From 6a078a0030bd87e8471f28f0ecc497ff86e31bab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:41:09 -0800 Subject: [PATCH 55/82] Blend: dashes on a closed key reversed from its own start read back from its end A closed key that winds the other way is reversed keeping its start anchor. Its dash offset was computed as if the reversed path ran back from position 0 instead of from the end of the subpath, so the dashes shifted by the subpath length modulo the pattern period. The offset now uses the full length in that case, and the dash test covers a closed key 40 long with a 7.5 period. --- crates/doc/src/blend.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index c269294c2f..2a7e115daf 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -727,8 +727,13 @@ fn reorient_dash(d: &mut Dash, o: Orient, lens: &[f64]) { pat.extend_from_within(..); } let period: f64 = pat.iter().sum(); - // Where along the key's own subpath the turned one starts (a closed one wraps round). - let at = if o.closed { o.seam() } else { o.start }; + // Where along the key's own subpath the turned one starts (a closed one wraps round). A + // closed one reversed from its own start runs back from its end. + let at = match (o.closed, o.seam()) { + (true, s) if s == 0.0 && o.reversed => 1.0, + (true, s) => s, + (false, _) => o.start, + }; let first = pat.first().copied().unwrap_or(0.0); let offset = |len: f64| if o.reversed { first - at * len - d.offset } else { d.offset + at * len }; let offsets: Vec = lens.iter().map(|&len| offset(len)).collect(); @@ -2963,6 +2968,23 @@ mod tests { let u = k as f64 / 10.0 + 0.03; assert_eq!(dash_at(&d, u), dash_at(&own, u + 20.0), "at {u}: {d:?}"); } + // A closed key that winds the other way runs the other way from the same anchor: its + // dashes are read back from the end of its own subpath, which is 40 long and not a whole + // number of periods. + let wound = |x: f64| { + let mut p = square(x); + for sp in &mut p.subpaths { + sp.reverse(); + } + p + }; + let back = keyed(wound(100.0), &graded(StrokeGradientMode::Along)); + let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &back, (Some(0), Some(0))).at(1.0)); + let (own, d) = (stroke(&back).dash.unwrap_or_default(), s.dash.unwrap_or_default()); + for k in 1..400 { + let u = k as f64 / 10.0 + 0.03; + assert_eq!(dash_at(&d, u), dash_at(&own, 40.0 - u), "at {u}: {d:?}"); + } // Stops that reflect twice read the same either way; a link to a swatch stays. let reflected = keyed(line(100.0), &|s| { let mut g = Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::default() }; From 9317ee06533b50852f51ad4101725253968a2b9c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:43:26 -0800 Subject: [PATCH 56/82] PDF: 16-bit TIFF test checks the written samples The tagged TIFF test now compares the decoded image data with the source for a 16-bit RGB TIFF whose high and low sample bytes differ: big-endian 16-bit samples at PDF 1.7, and the samples reduced to 8 bits at PDF 1.4. A byte-swapped or truncated stream no longer passes. --- crates/pdf/src/tests/compression.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 8b88a0731d..968bf1226c 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -349,6 +349,21 @@ fn image_spaces(pdf: &[u8]) -> Vec { out } +/// The decoded samples of each image XObject of `pdf` other than soft masks. +fn image_samples(pdf: &[u8]) -> Vec> { + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); + let images: Vec> = file + .objects() + .into_iter() + .filter_map(|o| match o { + Object::Stream(s) if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => Some(s), + _ => None, + }) + .collect(); + let masks: Vec> = images.iter().filter_map(|s| s.dict().get::>(b"SMask")).map(|m| m.raw_data().to_vec()).collect(); + images.iter().filter(|s| !masks.contains(&s.raw_data().to_vec())).map(|s| s.decoded().unwrap().into_owned()).collect() +} + /// A gray or RGB TIFF that carries an ICC profile of its colour space is written in an ICC-based /// colour space holding the same profile, at 16 bits a sample from PDF 1.5 on, with its alpha as /// a soft mask; and so is a tagged TIFF or PNG that is resampled or compressed with JPEG. Pixels @@ -415,6 +430,18 @@ fn tagged_tiffs_keep_their_icc_profile() { for (what, d, settings, want) in cases { assert_eq!(export(&d, settings), want, "{what}"); } + // 16-bit samples are written big-endian from PDF 1.5 on, and reduced to 8 bits before it. + // Their high and low bytes differ, so a swapped or truncated stream does not match. + let deep = DynamicImage::ImageRgb16(image::ImageBuffer::from_fn(600, 600, |x, y| { + image::Rgb([((x * 109 + y) % 65_521) as u16, ((y * 257 + 3) ^ 0x5a) as u16, ((x * y) % 65_521) as u16]) + })); + let sixteen: Vec = deep.to_rgb16().iter().flat_map(|v| v.to_be_bytes()).collect(); + for (compatibility, want) in [("1.7", sixteen), ("1.4", deep.to_rgb8().into_raw())] { + let mut settings: PdfSettings = serde_json::from_value(json!({ "compatibility": compatibility })).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&tiff(&deep, &rgb), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(image_samples(&r.bytes) == [want], "16-bit samples, PDF {compatibility}"); + } // A gray image in an RGB profile stays RGB to keep it, resampled too. let neutral = tiff(&DynamicImage::ImageRgb8(colour().grayscale().to_rgb8()), &rgb); for settings in [json!({}), json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}})] { From ebc82ca0d5754e488776a96d276edd722109597d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:43:39 -0800 Subject: [PATCH 57/82] Links: Sort by Size in the panel menu lists the smallest file first The Links panel menu gets Sort by Size beside Sort by Name, Kind and Status, and links.list takes sort: "size". A row's size is the file size Link Info shows: the file's size now, else the size the document keeps of it. Rows without a size (embedded images, a missing file whose size is not kept) come last, and equal sizes go by name. The size is read only when sorting by it, and under automation read roots a file outside them is not read. Every catalog with Sort by Status gets a Sort by Size row; the Ukrainian Sort by Status row, which read "Sort by:", now says "Sort by Status". --- crates/engine/src/cmd/links.rs | 34 +++++++++++++++++++--- crates/engine/src/tests/linkspanel.rs | 37 +++++++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 3 +- crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/links.rs | 2 +- crates/ui-egui/src/panels/tests_links.rs | 12 ++++++-- docs/mcp.md | 6 ++-- docs/target-app-parity.md | 1 + 33 files changed, 110 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 9a026598e8..ecc4a12bff 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs, patterns and stored brushes (one row per stored object, never one per symbol instance, pattern tile or brushed stroke) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern|brush, name? (of the symbol, graph design, pattern or brush), index? (a brush's place in the Brushes panel, from 0), tile?: side|outerCorner|innerCorner|start|end (a pattern brush's tile), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded)|size (the linked file's size in bytes as Link Info shows it, smallest first; rows without one, embedded images and missing files whose size is not kept, last; equal sizes by name); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs, patterns and stored brushes (one row per stored object, never one per symbol instance, pattern tile or brushed stroke) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern|brush, name? (of the symbol, graph design, pattern or brush), index? (a brush's place in the Brushes panel, from 0), tile?: side|outerCorner|innerCorner|start|end (a pattern brush's tile), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -2211,6 +2211,16 @@ fn document_row(p: &PlacedDocument) -> Value { json!({ "format": pdf.map_or("VectorCraft", |f| f.label), "document": true, "pageWidth": p.width, "pageHeight": p.height }) } +/// The size in bytes of the file that link `l` (of `st`) names, as Link Info shows it: the file's +/// size now, else the size the document keeps of it when the caller may see its path. +fn link_file_size(st: &crate::DocState, l: &LinkInfo) -> Option { + let doc_path = LinkBase::of(st).doc; + let kept = l.size.filter(|_| path_shown(&l.path, doc_path)); + let roots = crate::file_access::current().or_else(|| st.opened_by.clone()); + let now = crate::file_access::confine(roots.as_ref(), || fileio::document_path(&l.path, doc_path).ok().as_deref().and_then(file_stamp)); + now.map(|s| s.0).or(kept) +} + /// Sort rank of a `links.list` status: missing, modified, ok, embedded. fn status_rank(status: &str) -> usize { ["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4) @@ -2277,8 +2287,14 @@ fn list(s: &mut Session, p: &Value) -> Result { if !matches!(show, "all" | "missing" | "modified" | "embedded") { return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded"))); } + let sort = str_param(p, "sort"); + if let Some(x) = sort.filter(|x| !matches!(*x, "name" | "kind" | "status" | "size")) { + return Err(bad(C, format!("sort `{x}`: name, kind, status or size"))); + } let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() }; let statuses = statuses(st); + // The file size of each listed row's link, read only to sort by size. + let mut sizes: BTreeMap = BTreeMap::new(); // The layer tree's objects, top first as the Layers panel lists them, then the others in the // order they are stored (opacity-mask art, symbols, graph designs, patterns, the art of the // stored brushes), each once. @@ -2326,6 +2342,11 @@ fn list(s: &mut Session, p: &Value) -> Result { } let doc_path = LinkBase::of(st).doc; if let Some(l) = link { + if sort == Some("size") + && let Some(size) = link_file_size(st, l) + { + sizes.insert(n.id.0, size); + } note_path(&mut row, "path", &l.path, doc_path); if let Some(page) = l.page { row["page"] = json!(page); @@ -2346,12 +2367,17 @@ fn list(s: &mut Session, p: &Value) -> Result { rows.push(row); } let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase(); - match str_param(p, "sort") { - None => {} + match sort { Some("name") => rows.sort_by_cached_key(|r| text(r, "name")), Some("kind") => rows.sort_by_cached_key(|r| (text(r, "format"), text(r, "name"))), Some("status") => rows.sort_by_cached_key(|r| (status_rank(r["status"].as_str().unwrap_or_default()), text(r, "name"))), - Some(x) => return Err(bad(C, format!("sort `{x}`: name, kind or status"))), + // Smallest file first; rows without a file size (embedded images, a missing file whose + // size the document does not keep) come last; equal sizes by name. + Some("size") => rows.sort_by_cached_key(|r| { + let size = r["id"].as_u64().and_then(|id| sizes.get(&id).copied()); + (size.is_none(), size.unwrap_or(0), text(r, "name")) + }), + _ => {} } let count = |k: &str| counts.get(k).copied().unwrap_or(0); Ok(json!({ "links": rows, "missing": count("missing"), "modified": count("modified"), "embedded": count("embedded") })) diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index a9a3a17ec7..a1402cc43f 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -248,7 +248,42 @@ fn the_list_shows_each_image_with_its_status() { assert_eq!(run(&mut s, "links.list", json!({"show": "missing"}))["links"][0]["id"], linked.0); assert_eq!(run(&mut s, "links.list", json!({"sort": "name"}))["links"][0]["name"], "a.png"); assert!(s.execute("links.list", &json!({"show": "broken"})).is_err()); - assert!(s.execute("links.list", &json!({"sort": "size"})).is_err()); + assert!(s.execute("links.list", &json!({"sort": "date"})).is_err()); +} + +#[test] +fn sort_by_size_lists_the_smallest_file_first() { + let dir = Folder::new("list-size"); + let files = [("a.png", png(600, 300, RED)), ("b.png", png(10, 10, BLUE)), ("c.png", png(100, 100, RED)), ("d.png", png(10, 10, BLUE))]; + let mut s = session(); + for (name, bytes) in &files { + let path = dir.file(name); + write(&path, bytes); + place(&mut s, &path); + } + let e = dir.file("e.png"); + write(&e, &png(1, 1, RED)); + place_embedded(&mut s, &e); + let names = |s: &mut Session| -> Vec { + let l = run(s, "links.list", json!({"sort": "size"})); + l["links"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap_or_default().to_string()).collect() + }; + // Smallest first, equal sizes by name, the embedded image (no file) last. + let mut want: Vec<(usize, &str)> = files.iter().map(|(n, b)| (b.len(), *n)).collect(); + want.sort(); + assert!(want[0].0 < want[2].0 && want[2].0 < want[3].0, "{want:?}"); + let mut want: Vec = want.iter().map(|(_, n)| n.to_string()).collect(); + want.push("e.png".into()); + assert_eq!(names(&mut s), want); + // A missing file sorts by the size the document keeps of it. + std::fs::remove_file(dir.file("a.png")).unwrap(); + assert_eq!(names(&mut s), want); + // A modified file sorts by its size now. + let bigger = png(900, 900, BLUE); + assert!(bigger.len() > files[0].1.len()); + write(&dir.file("c.png"), &bigger); + let got = names(&mut s); + assert_eq!(got[got.len() - 2..], ["c.png".to_string(), "e.png".to_string()]); } #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28944173dd..a736f0afb7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3378,6 +3378,7 @@ digits Malayalam Малаялам Solid Плътен Sort by Kind Сортиране по вид Sort by Name Сортиране по име + Sort by Size Сортиране по размер Sort by Status Сортиране по състояние Source code on GitHub Изходен код в GitHub Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..491d47b3e5 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2246,6 +2246,7 @@ digits Malayalam Malajalam @msg paths entry {i} needs path and ids (object ids no other entry has) unos {i} u paths treba path i ids (ID-ove objekata koje nema nijedan drugi unos) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder uzima path: u folderu te datoteke traže se ostale nedostajuće veze Show Missing Prikaži nedostajuće + Sort by Size Sortiraj po veličini Sort by Status Sortiraj po statusu Clear Overrides Očisti zamjene Create New {kind} Style Kreiraj novi {kind} stil diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..7d429cba40 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4407,6 +4407,7 @@ digits Malayalam Malaiàlam Sort by Luminance Ordena per lluminositat Sort by Name Ordena per nom Sort by Popularity Ordena per popularitat + Sort by Size Ordena per mida Sort by Status Ordena per estat Sort Swatches Ordena les mostres Source code on GitHub Codi font a GitHub diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..56beb650a6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2277,6 +2277,7 @@ digits Malayalam Malajálamské @msg paths entry {i} needs path and ids (object ids no other entry has) položka {i} v paths potřebuje path a ids (ID objektů, které nemá žádná jiná položka) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder bere path: ve složce tohoto souboru se hledají ostatní chybějící propojení Show Missing Zobrazit chybějící + Sort by Size Seřadit podle velikosti Sort by Status Seřadit podle stavu Clear Overrides Vymazat přepsání Create New {kind} Style Vytvořit nový styl ({kind}) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b3de0ae89a..c40e8ba109 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2077,6 +2077,7 @@ digits Malayalam Malayalam @msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths skal have path og ids (objekt-id'er, som ingen anden post har) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tager path: der søges efter de andre manglende links i den fils mappe Show Missing Vis manglende + Sort by Size Sortér efter størrelse Sort by Status Sortér efter status Clear Overrides Ryd tilsidesættelser Create New {kind} Style Opret ny {kind}-typografi diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..f4b89ab2bf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3441,6 +3441,7 @@ effect Points Punkte Solid Gefüllt Sort by Kind Nach Art sortieren Sort by Name Nach Name sortieren + Sort by Size Nach Größe sortieren Sort by Status Nach Status sortieren Source code on GitHub Quellcode auf GitHub Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..df26f8c952 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3997,6 +3997,7 @@ digits Malayalam Μαλαγιάλαμ Social Square Post 1080×1080 Τετράγωνη δημοσίευση κοινωνικών δικτύων 1080×1080 Sort by Kind Ταξινόμηση κατά είδος Sort by Name Ταξινόμηση κατά όνομα + Sort by Size Ταξινόμηση κατά μέγεθος Sort by Status Ταξινόμηση κατά κατάσταση Source code on GitHub Πηγαίος κώδικας στο GitHub Space After Paragraph Διάστημα μετά την παράγραφο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..4d6a5ccbf7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3471,6 +3471,7 @@ digits Malayalam Malayálam Solid Sólido Sort by Kind Ordenar por tipo Sort by Name Ordenar por nombre + Sort by Size Ordenar por tamaño Sort by Status Ordenar por estado Source code on GitHub Código fuente en GitHub Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..8a070b02a7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2238,6 +2238,7 @@ digits Malayalam Malajalam @msg paths entry {i} needs path and ids (object ids no other entry has) paths-merkintä {i} tarvitsee path- ja ids-arvot (objektitunnukset, joita millään muulla merkinnällä ei ole) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder ottaa path-arvon: muita puuttuvia linkkejä haetaan tämän tiedoston kansiosta Show Missing Näytä puuttuvat + Sort by Size Lajittele koon mukaan Sort by Status Lajittele tilan mukaan Clear Overrides Tyhjennä ohitukset Create New {kind} Style Luo uusi tyyli: {kind} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..e1358f294b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3463,6 +3463,7 @@ digits Malayalam Malayalam Solid Plein Sort by Kind Trier par type Sort by Name Trier par nom + Sort by Size Trier par taille Sort by Status Trier par état Source code on GitHub Code source sur GitHub Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..cd1e5aad83 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4765,6 +4765,7 @@ antiAlias Sharp शार्प Sort by Luminance द्वारा क्रमबद्ध Luminance Sort by Name नाम से क्रमबद्ध करें Sort by Popularity लोकप्रियता द्वारा क्रमबद्ध + Sort by Size आकार द्वारा क्रमबद्ध Sort by Status स्थिति द्वारा क्रमबद्ध Sort By: द्वारा क्रमबद्ध: Sort Objects ऑब्जेक्ट्स को क्रमबद्ध करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a1f6100b2f..b599e5f1e4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4123,6 +4123,7 @@ mark-style Japanese Japán Sort by Luminance Rendezés fénysűrűség szerint Sort by Name Rendezés név szerint Sort by Popularity Rendezés gyakoriság szerint + Sort by Size Rendezés méret szerint Sort by Status Rendezés állapot szerint Source code on GitHub Forráskód a GitHubon Source Image Forráskép diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c7c6b956db..b04fedeafc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2077,6 +2077,7 @@ digits Malayalam Malayalam @msg paths entry {i} needs path and ids (object ids no other entry has) entri paths {i} memerlukan path dan ids (id objek yang tidak dimiliki entri lain) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder menerima path: tautan hilang lainnya dicari di folder file tersebut Show Missing Tampilkan yang hilang + Sort by Size Urutkan berdasarkan ukuran Sort by Status Urutkan berdasarkan status Clear Overrides Hapus penimpaan Create New {kind} Style Buat gaya {kind} baru diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6743704785..2485893186 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3516,6 +3516,7 @@ digits Malayalam Malayalam Solid Pieno Sort by Kind Ordina per tipo Sort by Name Ordina per nome + Sort by Size Ordina per dimensione Sort by Status Ordina per stato Source code on GitHub Codice sorgente su GitHub Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9cdb8fcbe..83b8ea1bc6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4023,6 +4023,7 @@ file-filter Templates テンプレート Small Thumbnails 小さいサムネール Smooth Color スムーズカラー Social Square Post 1080×1080 ソーシャル正方形投稿 1080×1080 + Sort by Size サイズ順に並べ替え Sort by Status 状態順に並べ替え Specified Distance 距離 Specified Steps ステップ数 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..ac3456b931 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2005,6 +2005,7 @@ digits Malayalam მალაიალამური @msg paths entry {i} needs path and ids (object ids no other entry has) paths-ის ჩანაწერს {i} სჭირდება path და ids (ობიექტის ID-ები, რომლებიც სხვა ჩანაწერს არ აქვს) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder იღებს path-ს: სხვა დაკარგული ბმულები ამ ფაილის საქაღალდეში იძებნება Show Missing დაკარგულების ჩვენება + Sort by Size ზომის მიხედვით სორტირება Sort by Status სტატუსის მიხედვით სორტირება Clear Overrides ცვლილებების გასუფთავება Create New {kind} Style ახალი სტილის შექმნა ({kind}) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..479c35ed50 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4011,6 +4011,7 @@ file-filter Templates 템플릿 Small Thumbnails 작은 축소판 Smooth Color 부드러운 색상 Social Square Post 1080×1080 소셜 정사각형 게시물 1080×1080 + Sort by Size 크기순 정렬 Sort by Status 상태순 정렬 Specified Distance 지정 거리 Specified Steps 지정 단계 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..70f3a6aad8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2595,6 +2595,7 @@ digits Malayalam Malayalam @msg paths entry {i} needs path and ids (object ids no other entry has) item {i} van paths heeft path en ids nodig (object-id's die geen ander item heeft) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder neemt path: in de map van dat bestand wordt naar de andere ontbrekende koppelingen gezocht Show Missing Ontbrekende weergeven + Sort by Size Sorteren op grootte Sort by Status Sorteren op status Clear Overrides Onderdrukkingen wissen Create New {kind} Style Nieuwe {kind}stijl maken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..5d3de88c7a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2196,6 +2196,7 @@ digits Malayalam Malajalam @msg paths entry {i} needs path and ids (object ids no other entry has) wpis {i} w paths wymaga path i ids (identyfikatorów obiektów, których nie ma żaden inny wpis) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder przyjmuje path: w folderze tego pliku szukane są pozostałe brakujące łącza Show Missing Pokaż brakujące + Sort by Size Sortuj według rozmiaru Sort by Status Sortuj według stanu Clear Overrides Wyczyść zastąpienia Create New {kind} Style Utwórz nowy styl: {kind} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..767c78ff24 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5201,6 +5201,7 @@ axis Both Ambos Corner Canto Smooth Color Cor Suave Social Square Post 1080×1080 Post Social Quadrado 1080×1080 + Sort by Size Ordenar por Tamanho Sort by Status Ordenar por Status Source Sans 3 Source Sans 3 Specified Distance Distância Especificada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..5c41fea970 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3503,6 +3503,7 @@ digits Malayalam Малаялам Solid Сплошной Sort by Kind Сортировать по типу Sort by Name Сортировать по имени + Sort by Size Сортировать по размеру Sort by Status Сортировать по состоянию Source code on GitHub Исходный код на GitHub Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..cff877a7ad 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3393,6 +3393,7 @@ digits Malayalam Malayalam Solid Enfärgad Sort by Kind Sortera efter typ Sort by Name Sortera efter namn + Sort by Size Sortera efter storlek Sort by Status Sortera efter status Source code on GitHub Källkod på GitHub Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..907fc4f9a6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3267,6 +3267,7 @@ digits Malayalam Malayalam Solid Katı Sort by Kind Türe göre sırala Sort by Name Ada Göre Sırala + Sort by Size Boyuta göre sırala Sort by Status Duruma göre sırala Source code on GitHub GitHub'daki kaynak kodu Source Sans 3 Kaynak Sans 3 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..c78b864c2d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3509,7 +3509,8 @@ effect Points Точки Solid Суцільний Sort by Kind Сортувати за типом Sort by Name Сортувати за назвою - Sort by Status Сортувати за: + Sort by Size Сортувати за розміром + Sort by Status Сортувати за станом Source code on GitHub Вихідний код GitHub Source Sans 3 Source Sans 3 Space After Paragraph Інтервал після абзацу diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..27355ae856 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3857,6 +3857,7 @@ mark-style Japanese Kiểu Nhật Sort Sắp xếp Sort by Kind Sắp xếp theo loại Sort by Name Sắp xếp theo tên + Sort by Size Sắp xếp theo kích thước Sort by Status Sắp xếp theo trạng thái Source code on GitHub Mã nguồn trên GitHub Source Image Ảnh nguồn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..cd2c86aa1e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3332,6 +3332,7 @@ digits Malayalam 马拉雅拉姆数字 Solid 实心 Sort by Kind 按类型排序 Sort by Name 按名称排序 + Sort by Size 依大小排序 Sort by Status 依状态排序 Source code on GitHub GitHub 源代码 Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..e5dd468d37 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4589,6 +4589,7 @@ digits Malayalam 馬來亞拉姆數字 Solid 實心 Sort by Kind 依類型排序 Sort by Name 依名稱排序 + Sort by Size 依大小排序 Sort by Status 依狀態排序 Source code on GitHub GitHub 上的原始碼 Source Sans 3 Source Sans 3 diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index f9d9b524c9..244a67ddb8 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -29,7 +29,7 @@ pub const ID: &str = "links"; /// Seconds between two looks at the linked files (they change outside the app). const RECHECK: f64 = 2.0; const SHOW: [(&str, &str); 4] = [("all", "Show All"), ("missing", "Show Missing"), ("modified", "Show Modified"), ("embedded", "Show Embedded")]; -const SORT: [(&str, &str); 3] = [("name", "Sort by Name"), ("kind", "Sort by Kind"), ("status", "Sort by Status")]; +const SORT: [(&str, &str); 4] = [("name", "Sort by Name"), ("kind", "Sort by Kind"), ("status", "Sort by Status"), ("size", "Sort by Size")]; const THUMBS: [(f32, &str); 4] = [(0.0, "No Thumbnails"), (18.0, "Small Thumbnails"), (28.0, "Medium Thumbnails"), (44.0, "Large Thumbnails")]; /// The panel's options (flyout). diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 6b773e9d3b..8b3e065a64 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -90,8 +90,16 @@ fn the_panel_lists_the_images_with_link_info_for_the_selected_one() { assert!(text.contains("Linked") && text.contains("300 × 200 px") && text.contains("Location"), "{text}"); // The flyout draws with every item. let menu = painted_text(&mut app, super::links::menu); - for item in ["Relink to Folder…", "Embed Image(s)", "Placement Options…", "Show Missing", "Sort by Status", "Large Thumbnails", "Show in Folder"] - { + for item in [ + "Relink to Folder…", + "Embed Image(s)", + "Placement Options…", + "Show Missing", + "Sort by Status", + "Sort by Size", + "Large Thumbnails", + "Show in Folder", + ] { assert!(menu.contains(item), "{item}: {menu}"); } } diff --git a/docs/mcp.md b/docs/mcp.md index 2e43c14d0e..b844faca6d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7434,8 +7434,10 @@ listed in `warnings`. ASCII and binary DXF files are read; DWG files answer an e ## Links panel -`links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top -first, as the Links panel does, then every image and placed document in opacity-mask art and in the art of symbols, +`links.list {show?: all|missing|modified|embedded, sort?: name|kind|status|size}` lists every image in the layers, top +first, as the Links panel does (`sort: "size"`, the panel menu's Sort by Size, puts the smallest file first by the +size Link Info shows; rows without one, embedded images and missing files whose size is not kept, come last, and equal +sizes go by name), then every image and placed document in opacity-mask art and in the art of symbols, graph designs, patterns and stored brushes, one row per stored object (never one per symbol instance, pattern tile or brushed stroke): `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?, page?, preview?, note?, owner?}` (`owner`, for an object outside the layer tree: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6375f3aa3..80a3ab8623 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1850,6 +1850,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. + - The Links panel menu's Sort by Size ([Links panel](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/links-panel-in-illustrator.html)) lists the links by file size, smallest first (`links.list {sort: "size"}`). VectorCraft's own rules: a file's size is the one Link Info shows, rows without one (embedded images, a missing file whose size the document does not keep) come last, and equal sizes go by name. - When a document opens with notes and with linked files that changed on disk (Update Links: Manually) or, on the web, can't be read, the status bar shows the line about the linked files, then counts the notes and names the first, and hovering it lists the notes. The same holds when the line about modified files follows the Linked File Not Found dialog, and File › New from Template… shows the notes of the file it opens as File › Open does (`an_open_with_modified_links_keeps_link_notice_and_import_notes_reachable`, `new_from_template_preserves_open_warnings`, `an_open_with_unreadable_links_keeps_import_notes_reachable`, `modified_links_after_the_missing_ones_keep_the_open_notes`, `combined_open_and_link_status_lists_notes_on_hover_and_for_accessibility`). - On the web, when a document opens with linked files that can't be read and linked files that changed on disk (Update Links: Manually), the status bar shows the line about the unreadable files, and the line about the changed files is the first of the notes its hover lists, ahead of the open's notes. With Ask When Modified the update question opens and the line about the unreadable files stays (`a_web_open_with_unreadable_and_modified_links_keeps_both_lines`). - Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id. From 9d0a4eb731a0314ea806ba1ca5ffec2b46e5f796 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:47:28 -0800 Subject: [PATCH 58/82] Shape Builder: rewrap the shape_builder_shapes doc comment --- crates/tools/src/builder/lookup.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index e8e261e5a4..8e17823332 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -592,7 +592,8 @@ fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], builder: bool) -> (Vec(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { let (mut shapes, leaves) = shapes_of(doc, roots, true); for (s, l) in shapes.iter_mut().zip(&leaves) { From aa3e74384ba5492d5d43b14875f71dad402977b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:11:04 -0800 Subject: [PATCH 59/82] PDF: PDF/X-1a test covers a tagged 16-bit gray TIFF The PDF/X-1a case of tagged_tiffs_keep_their_icc_profile also exports a 16-bit gray TIFF with a gray profile, kept as it is, resampled and compressed with JPEG, and checks each written file with the PDF/X-1a check as well as its image color space. --- crates/pdf/src/tests/compression.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 968bf1226c..e8c750f024 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -455,18 +455,22 @@ fn tagged_tiffs_keep_their_icc_profile() { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } - // PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF, PNG or JPEG is written in - // DeviceGray, kept as it is, resampled or compressed with JPEG. + // PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF (8 or 16 bits), PNG or JPEG is + // written in DeviceGray, kept as it is, resampled or compressed with JPEG, and the file passes + // the PDF/X-1a check. let compressions = [json!({}), json!({"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"gray": {"compression": "jpeg"}})]; + let tiff16 = tiff(&DynamicImage::ImageLuma16(colour().to_luma16()), &gray); let png = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png"); let jpeg = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Jpeg), "image/jpeg"); - for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png), ("JPEG", jpeg)] { + for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("TIFF, 16 bits", tiff16), ("PNG", png), ("JPEG", jpeg)] { for compression in &compressions { let mut settings: PdfSettings = serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); settings.compression.compress_text = false; let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }); - let spaces = image_spaces(&r.unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")).bytes); + let bytes = r.unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")).bytes; + crate::pdfx::verify(&bytes, Standard::PdfX1a).unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")); + let spaces = image_spaces(&bytes); let gray = spaces.len() == 1 && spaces[0].space == "DeviceGray" && spaces[0].icc.is_none(); assert!(gray, "PDF/X-1a, {format}, {compression}: {spaces:?}"); } From 163f2564181fc35163ee93af653c5f099d3c4bda Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:24:17 -0800 Subject: [PATCH 60/82] Perspective: Perspective Grid Options has Automatic Plane Positioning Perspective Grid Options gets the two Automatic Plane Positioning checkboxes, Anchor Point of Perspective Artwork and Intersection of Gridlines. With the Perspective Selection tool, Shift held over an anchor point of art in perspective, or over a gridline intersection, with no button down, moves the active plane through that point and hides the other planes. A plane made active then passes through the same point. The plane goes back to its place once an object is drawn on it or attached to it, when the grid hides or no plane is active, when the file is opened again, and with Escape. - perspective.plane.infer {at?, tolerance?, clear?} positions, reads and clears the held point, which is view state, not an undo step. - perspective.widget.options takes anchorPoint and gridlineIntersection, both on by default and kept with the preferences. - perspective.plane.move moves a held plane from where it is held. --- .../engine/src/cmd/distortcmds/perspective.rs | 55 ++- crates/engine/src/cmd/perspgrid.rs | 130 ++++++- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/interaction.rs | 3 + crates/engine/src/session/documents.rs | 3 + crates/engine/src/session/edit.rs | 9 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/persp_infer.rs | 273 ++++++++++++++ crates/engine/src/tests/perspgrid.rs | 5 +- crates/engine/src/tooling.rs | 4 +- crates/tools/src/context.rs | 2 + crates/tools/src/distort/perspective.rs | 64 +++- .../tools/src/distort/perspective/define.rs | 1 + crates/tools/src/distort/perspective/infer.rs | 351 ++++++++++++++++++ crates/tools/src/distort/perspective/tools.rs | 9 +- .../tools/src/distort/perspective/widget.rs | 31 +- crates/tools/src/lib.rs | 1 + .../src/dialogs/perspective_options.rs | 15 +- .../ui-egui/src/dialogs/tests_perspective.rs | 20 + docs/mcp.md | 27 +- docs/target-app-parity.md | 1 + 21 files changed, 958 insertions(+), 48 deletions(-) create mode 100644 crates/engine/src/tests/persp_infer.rs create mode 100644 crates/tools/src/distort/perspective/infer.rs diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 89091248df..647f4a4342 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -70,13 +70,26 @@ pub(super) fn grid_show(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(_) => Some(switch_or(p, "visible", false, C)?), }; - let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?; + // A hidden grid holds no plane for Automatic Plane Positioning. + let g = silent(s, |g| { + g.visible = want.unwrap_or(!g.visible); + if !g.visible { + g.inferred = None; + } + })?; Ok(json!({ "visible": g.visible })) } pub(super) fn plane_set(s: &mut Session, p: &Value) -> Result { let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?; - silent(s, |g| g.plane = plane)?; + // The new active plane passes through the point Automatic Plane Positioning holds, unless no + // plane is active or that would put the plane behind the viewer. + silent(s, |g| { + g.plane = plane; + if g.inferred.is_some() && (plane == Plane::None || g.validate().is_err()) { + g.inferred = None; + } + })?; Ok(json!({ "plane": plane.id() })) } @@ -138,6 +151,9 @@ pub(super) fn attach(s: &mut Session, p: &Value) -> Result { store_grid(d, &g); Ok(()) })?; + if !ids.is_empty() { + crate::cmd::perspgrid::plane_positioning_used(s, plane)?; + } Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "plane": plane.id() })) } @@ -205,8 +221,13 @@ fn persp_edit( return Err(EngineError::Other("select the objects to transform in perspective".into())); } let mut g = grid_of(&s.doc()?.doc); + // An object not in perspective yet attaches to `fallback` where that plane is now. + let attaches = { + let d = &s.doc()?.doc; + fallback.filter(|_| ids.iter().any(|id| g.attachment_of(d, *id).is_none())) + }; let kept = if copy && !stores_attachments(&s.doc()?.doc) { crate::cmd::object::preview_copies(s, ids, false)? } else { None }; - s.edit(label, |d, sel| { + let edited = s.edit(label, |d, sel| { g.adopt_stored_attachments(d); let targets = match &kept { // The document becomes the snapshot with the kept copies, not yet mapped, and the loop @@ -236,7 +257,11 @@ fn persp_edit( } store_grid(d, &g); Ok((targets, first)) - }) + })?; + if let Some(plane) = attaches { + crate::cmd::perspgrid::plane_positioning_used(s, plane)?; + } + Ok(edited) } /// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one @@ -430,6 +455,9 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["offset", "by"], C)?; let old = grid_of(&s.doc()?.doc); let plane = plane_param(&old, p, C)?; + // A plane that Automatic Plane Positioning holds moves from where it is held, and the move + // places it there for good: the moved grid holds no plane (undoing the move holds it again). + let held = old.inferred; let from = old.offset(plane); let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) { (Some(o), _) => o, @@ -440,7 +468,7 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result { if !matches!(objects, "none" | "move" | "copy") { return Err(bad(C, "objects must be none, move or copy")); } - let mut g = old.clone(); + let mut g = PerspectiveGrid { inferred: None, ..old.clone() }; g.set_offset(plane, to); g.validate().map_err(|e| bad(C, e))?; let label = match objects { @@ -458,8 +486,9 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result { .collect() }; // A plane moved to where it is, with its objects or without them, moves nothing: when the - // document holds the grid already, the document and its undo history stay as they are. - if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) { + // document holds the grid already, the document and its undo history stay as they are (a + // plane held where it was placed stays held). + if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &PerspectiveGrid { inferred: held, ..g.clone() }) { let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] }; return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })); } @@ -507,12 +536,19 @@ pub(super) fn plane_match(s: &mut Session, p: &Value) -> Result { named_id(p)?.or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select an object in perspective".into()))?; let mut g = grid_of(&st.doc); let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?; + // The moved grid holds no plane for Automatic Plane Positioning (undoing the move holds it + // again). + let held = g.inferred.take(); g.set_offset(plane, depth); g.plane = plane; g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?; // When the plane is at the object and active already, and the document holds that grid, the - // document and its undo history stay as they are. - if grid_kept(&st.doc, &g) { + // document and its undo history stay as they are; a plane held elsewhere goes back to that + // place as view state. + if grid_kept(&st.doc, &PerspectiveGrid { inferred: held, ..g.clone() }) { + if held.is_some() { + silent(s, |g| g.inferred = None)?; + } return Ok(json!({ "plane": plane.id(), "offset": depth })); } s.edit("Move Plane to Match Object", |d, _| { @@ -573,6 +609,7 @@ pub(super) fn persp_draw(s: &mut Session, p: &Value) -> Result { st.history.recost_last(&st.doc); } res?; + crate::cmd::perspgrid::plane_positioning_used(s, plane)?; Ok(json!({ "id": id.0, "plane": plane.id() })) } diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index aaeee36d80..150d716421 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -7,7 +7,8 @@ //! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them. //! //! View options (Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) are view state on -//! the grid: they toggle without an undo step, as Show Grid does. +//! the grid: they toggle without an undo step, as Show Grid does. So is the point Automatic Plane +//! Positioning holds the active plane through (`perspective.plane.infer`). use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -15,12 +16,15 @@ use vectorcraft_doc::{Document, NodeId}; use vectorcraft_geom::{Point, Rect, Vec2}; use vectorcraft_tools::distort::perspective::Plane; use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin}; +use vectorcraft_tools::distort::perspective::infer::{HIT_PX, InferSource}; use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace}; -use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid}; +use vectorcraft_tools::distort::perspective::{GridDefinition, MAX_DEPTH, PerspectiveGrid}; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; use super::distortcmds::{grid_kept, grid_of, silent, store_grid}; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, switch_or, unique_name}; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, number_or, str_param, switch_or, unique_name, +}; pub fn specs() -> Vec { vec![ @@ -92,10 +96,19 @@ pub fn specs() -> Vec { "Perspective Grid Options", [], None, - "{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight} the Plane Switching Widget: shown or not, and the corner of the document window it stays in (kept with the preferences; no params reads them) → {show, position}", + "{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight, anchorPoint?: bool, gridlineIntersection?: bool} the Plane Switching Widget: shown or not, and the corner of the document window it stays in; Automatic Plane Positioning: whether it takes a point from an anchor point of art in perspective and from a gridline intersection (both on by default; see perspective.plane.infer). Kept with the preferences; no params reads them → {show, position, anchorPoint, gridlineIntersection}", always, widget_options ), + cmd!( + "perspective.plane.infer", + "Automatic Plane Positioning", + [], + None, + "{at?: [x, y], tolerance?: pt around `at` (default 6), clear?: bool} with `at` on an anchor point of shown art in perspective, or on a gridline intersection of a plane that shows, the active plane moves through that point for now and the other planes hide (the Perspective Selection tool: Shift held over one with no button down). An anchor point within reach wins over an intersection, and perspective.widget.options turns either off. Making another plane active (perspective.plane.set) brings that plane through the same point. The plane goes back to its place when an object is drawn on it or attached to it (perspective.draw, perspective.attach, an object brought into perspective by perspective.move or perspective.transform), when a plane is moved (perspective.plane.move, perspective.plane.matchObject), when the grid hides or no plane is active, when the file is opened again, and with `clear` (Escape with the Perspective Selection tool). View state: not an undo step; no params reads it → {active, plane, offset: where the active plane is, point?: [x, y, z] in the grid's frame, source?: anchor|gridline (what `at` found; absent when nothing was within reach)}", + has_doc, + plane_infer + ), cmd!( "perspective.grid.lock", "Lock Grid", @@ -259,21 +272,32 @@ pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result { let old = grid_of(&s.doc()?.doc); let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?; let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?; - // The objects stay attached and the view options stay as they were. - let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g }; + // The objects stay attached and the view options stay as they were, and so does a plane held + // by Automatic Plane Positioning while the new grid can hold it. + let mut g = PerspectiveGrid { + attached: old.attached, + locked: old.locked, + lock_station: old.lock_station, + snap: old.snap, + rulers: old.rulers, + inferred: old.inferred, + ..g + }; + g.keep_positioning_valid(); // When the document holds this grid already, apart from whether it shows and its active plane, // the preset only sets those two, without an undo step (as perspective.grid.show and // perspective.plane.set do). - let view = PerspectiveGrid { visible: old.visible, plane: old.plane, ..g.clone() }; + let view = PerspectiveGrid { visible: old.visible, plane: old.plane, inferred: old.inferred, ..g.clone() }; if !grid_kept(&s.doc()?.doc, &view) { s.edit("Perspective Grid Preset", |d, _| { store_grid(d, &g); Ok(()) })?; - } else if (old.visible, old.plane) != (g.visible, g.plane) { + } else if (old.visible, old.plane, old.inferred) != (g.visible, g.plane, g.inferred) { silent(s, |cur| { cur.visible = g.visible; cur.plane = g.plane; + cur.inferred = g.inferred; })?; } Ok(g.definition_json()) @@ -460,10 +484,100 @@ fn widget_options(s: &mut Session, p: &Value) -> Result { o.position = v.as_str().and_then(WidgetCorner::parse).ok_or_else(|| bad(C, "position must be topLeft, topRight, bottomLeft or bottomRight"))?; } + if let Some(v) = p.get("anchorPoint") { + o.anchor_point = v.as_bool().ok_or_else(|| bad(C, "anchorPoint must be true or false"))?; + } + if let Some(v) = p.get("gridlineIntersection") { + o.gridline_intersection = v.as_bool().ok_or_else(|| bad(C, "gridlineIntersection must be true or false"))?; + } s.prefs.perspective_widget = o; Ok(json!(o)) } +/// What `perspective.plane.infer` reports for `g`. +fn infer_info(g: &PerspectiveGrid, source: Option) -> Value { + let active = g.inferred.is_some() && g.plane != Plane::None; + let mut v = json!({ "active": active, "plane": g.plane.id(), "offset": g.offset(g.plane) }); + if let Some(at) = g.inferred.filter(|_| active) { + v["point"] = json!(at); + } + if let Some(source) = source { + v["source"] = json!(source.id()); + } + v +} + +fn plane_infer(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.plane.infer"; + let clear = switch_or(p, "clear", false, C)?; + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?), + }; + let tol = number_or(p, "tolerance", HIT_PX, C)?; + if !(tol.is_finite() && (0.0..=MAX_DEPTH).contains(&tol)) { + return Err(bad(C, "tolerance must be 0 or more")); + } + if clear && at.is_some() { + return Err(bad(C, "give `at` or `clear`, not both")); + } + let doc = &s.doc()?.doc; + let g = PerspectiveGrid::current(doc); + let mut source = None; + let next = match at { + _ if clear => None, + None => g.inferred, + Some(at) => { + if !g.visible { + return Err(EngineError::Other("the perspective grid is hidden".into())); + } + if g.plane == Plane::None { + return Err(EngineError::Other("no active perspective plane".into())); + } + let targets = s.prefs.perspective_widget.targets(); + if !targets.any() { + return Err(EngineError::Other( + "Automatic Plane Positioning is off: turn on anchorPoint or gridlineIntersection (perspective.widget.options)".into(), + )); + } + match g.infer_at(doc, at, tol, targets) { + Some((found, point)) => { + source = Some(found); + Some(point) + } + None => g.inferred, + } + } + }; + if next == g.inferred { + return Ok(infer_info(&g, source)); + } + // A plane through the point stays in front of the viewer. + if next.is_some() { + PerspectiveGrid { inferred: next, ..g }.validate().map_err(|e| bad(C, e))?; + } + let g = silent(s, |g| g.inferred = next)?; + Ok(infer_info(&g, source)) +} + +/// An object was drawn on, or attached to, `plane`: when Automatic Plane Positioning holds that +/// plane, it goes back to its place as view state, not an undo step (a plane it doesn't hold +/// leaves it as it is). In a drag that happens once the drag is committed +/// ([`Session::commit_interaction`]), so the plane stays put while the object is drawn. +pub(crate) fn plane_positioning_used(s: &mut Session, plane: Plane) -> Result<()> { + let st = s.doc_mut()?; + let g = grid_of(&st.doc); + if g.inferred.is_none() || g.plane != plane { + return Ok(()); + } + if let Some(it) = &mut st.interaction { + it.plane_placed = true; + return Ok(()); + } + silent(s, |g| g.inferred = None)?; + Ok(()) +} + impl Session { /// The Plane Switching Widget's part of a pointer event: a press on it picks the plane (its /// drag and release go nowhere); None when the event isn't the widget's. diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 5329f23331..4f7aeaef4c 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -473,6 +473,7 @@ pub(super) const ALLOWED: &[&str] = &[ "perspective.grid.snap", "perspective.move", "perspective.nudge", + "perspective.plane.infer", "perspective.plane.matchObject", "perspective.plane.move", "perspective.plane.set", diff --git a/crates/engine/src/interaction.rs b/crates/engine/src/interaction.rs index e3cd563d55..99a2569005 100644 --- a/crates/engine/src/interaction.rs +++ b/crates/engine/src/interaction.rs @@ -24,6 +24,9 @@ pub struct Interaction { /// The perspective transform the previews make (`perspective.transform` params): Transform /// Again repeats it once the drag is committed. pub perspective_again: Option, + /// A preview drew an object on, or attached one to, a plane held by Automatic Plane + /// Positioning: the plane goes back to its place once the drag is committed. + pub(crate) plane_placed: bool, /// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here /// ([`PreviewCopies`]). pub(crate) copies: Option>, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a87b47be0e..e49c22a0bb 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -329,6 +329,8 @@ impl Session { // Ruler guides a reader left in the saved list become nodes on their layers. doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); + // A plane held by Automatic Plane Positioning when the file was saved is back in its place. + vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(&mut doc); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); st.history.limit = self.prefs.history_states as usize; @@ -357,6 +359,7 @@ impl Session { doc.resolve_inline_art(); doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); + vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(&mut doc); // In the back, type set in a font named like a composite font is measured again below, in // the document's own composite fonts, not in the front document's. let back = self.active != Some(index); diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 73977f7d64..4f47b325b9 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -289,6 +289,7 @@ impl Session { variables_highlight: st.variables_highlight.clone(), isolation: st.isolation, perspective_again: None, + plane_placed: false, copies: None, move_box: std::sync::OnceLock::new(), }); @@ -319,6 +320,7 @@ impl Session { it.preview_paint = None; it.color_mode_paints = None; it.deferred_color_mode = None; + it.plane_placed = false; if selection_changed { st.selection.clone_from(&it.selection); } @@ -378,6 +380,7 @@ impl Session { after.clone_from(&paint); } let perspective_again = it.perspective_again.take(); + let plane_placed = it.plane_placed; let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); // Alt pressed or released during a move drag turns it into a copy or back: name it for // what it did in the end. @@ -395,6 +398,12 @@ impl Session { preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0), ); st.keep_interaction(it); + // The drag drew an object on, or attached one to, the plane Automatic Plane Positioning + // held: the plane is back in its place now, as view state (undoing the drag holds it again). + if plane_placed && changed && vectorcraft_tools::distort::perspective::PerspectiveGrid::positioning(&st.doc) { + vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(Arc::make_mut(&mut st.doc)); + st.revision += 1; + } if preview.0 == "object.transform" && changed { let m = cmd::matrix_param(&preview.1, "matrix"); if let Some(m) = m { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2f849901a4..7afdc59947 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -128,6 +128,7 @@ mod pdffidelity; mod pdfmarks; mod pdfpresets; mod pdfraster; +mod persp_infer; mod persp_planes; pub(super) mod persp_select; mod persp_text; diff --git a/crates/engine/src/tests/persp_infer.rs b/crates/engine/src/tests/persp_infer.rs new file mode 100644 index 0000000000..79569a2437 --- /dev/null +++ b/crates/engine/src/tests/persp_infer.rs @@ -0,0 +1,273 @@ +//! Automatic Plane Positioning: Shift held over an anchor point of art in perspective or over a +//! gridline intersection moves the active plane through it for now (`perspective.plane.infer`), +//! the two Perspective Grid Options that turn the targets on and off, and what ends it. + +use serde_json::json; +use vectorcraft_geom::{Point, Vec2}; +use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::tests_persp_select::{attached, bounds_of, drag, grid, persp_session, plane_pts, undo_len}; +use crate::*; + +fn shift() -> Mods { + Mods { shift: true, ..Default::default() } +} + +/// The top anchor of path `id` (on the right wall at depth 0): its page point and its height. +fn top_anchor(s: &Session, id: NodeId) -> (Point, f64) { + let page: Vec = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect(); + let plane = plane_pts(s, id, Plane::Right, 0.0); + page.into_iter().zip(plane).map(|(p, q)| (p, q.y)).max_by(|a, b| a.1.total_cmp(&b.1)).unwrap() +} + +fn hover(s: &mut Session, p: Point, mods: Mods) { + s.pointer(&PointerEvent::new(PointerKind::Move, p.x, p.y).with_mods(mods), ViewInfo::default()).unwrap(); +} + +fn revision(s: &Session) -> u64 { + s.doc().unwrap().revision +} + +#[test] +fn shift_over_an_anchor_holds_the_active_plane_at_its_height_until_an_object_is_drawn() { + let (mut s, id) = persp_session(); + let v = ViewInfo::default(); + let (anchor, height) = top_anchor(&s, id); + assert!(height > 10.0, "{height}"); + s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap(); + s.select_tool("perspectiveSelection", v).unwrap(); + let steps = undo_len(&s); + // Without Shift the pointer only hovers. + hover(&mut s, anchor, Mods::default()); + assert_eq!(grid(&s).inferred, None); + hover(&mut s, anchor, shift()); + let g = grid(&s); + assert!(g.inferred.is_some()); + assert!((g.offset(Plane::Ground) - height).abs() < 1e-6, "{} {height}", g.offset(Plane::Ground)); + assert_eq!((g.ground_offset, g.shown_planes(), undo_len(&s)), (0.0, vec![Plane::Ground], steps)); + // Held there already, another hover over the anchor changes nothing. + let rev = revision(&s); + hover(&mut s, anchor, shift()); + assert_eq!(revision(&s), rev); + // The Rectangle tool draws on the plane where it is held, and the plane stays put for the drag. + s.select_tool("rectangle", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 400.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 470.0, 430.0), v).unwrap(); + assert!(grid(&s).inferred.is_some(), "held while the object is drawn"); + s.pointer(&PointerEvent::new(PointerKind::Up, 470.0, 430.0), v).unwrap(); + let drawn = s.doc().unwrap().selection.objects[0]; + assert_ne!(drawn, id); + let (plane, depth) = attached(&s, drawn).unwrap(); + assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}"); + // Drawn, the plane is back in its place and the other planes show: one undo step for the drag. + let g = grid(&s); + assert_eq!((g.inferred, g.offset(Plane::Ground), g.shown_planes().len(), undo_len(&s)), (None, 0.0, 3, steps + 1)); + // Undoing the drag holds the plane again; Escape with the Perspective Selection tool ends that. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(grid(&s).inferred.is_some()); + s.select_tool("perspectiveSelection", v).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(grid(&s).inferred, None); +} + +#[test] +fn shift_during_a_drag_still_constrains_the_move() { + let (mut s, id) = persp_session(); + let v = ViewInfo::default(); + s.select_tool("perspectiveSelection", v).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let h = grid(&s).homography(Plane::Right).unwrap(); + // From the middle of the object, along the wall and a little up. + let start = bounds_of(&before).center(); + let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap()); + drag(&mut s, &[from, to, to], shift()); + assert_eq!(grid(&s).inferred, None, "with a button down Shift constrains the move"); + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { + assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "{a:?} → {b:?}"); + } +} + +#[test] +fn a_gridline_intersection_brings_each_plane_made_active_through_it() { + let (mut s, _) = persp_session(); + let (g, steps) = (grid(&s), undo_len(&s)); + s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); + let at = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap(); + let r = s.execute("perspective.plane.infer", &json!({"at": [at.x, at.y], "tolerance": 1})).unwrap(); + assert_eq!((r["active"].as_bool(), r["source"].as_str(), r["plane"].as_str()), (Some(true), Some("gridline"), Some("left"))); + for (got, want) in r["point"].as_array().unwrap().iter().zip([0.0, 2.0 * g.cell, 3.0 * g.cell]) { + assert!((got.as_f64().unwrap() - want).abs() < 1e-6, "{r}"); + } + for (plane, want) in [("ground", 2.0 * g.cell), ("right", 3.0 * g.cell), ("left", 0.0)] { + s.execute("perspective.plane.set", &json!({"plane": plane})).unwrap(); + let r = s.execute("perspective.plane.infer", &json!({})).unwrap(); + assert!((r["offset"].as_f64().unwrap() - want).abs() < 1e-6 && r["active"] == json!(true) && r.get("source").is_none(), "{plane}: {r}"); + } + let held = grid(&s); + assert_eq!((held.left_offset, held.right_offset, held.ground_offset, undo_len(&s)), (0.0, 0.0, 0.0, steps)); + // Nothing within reach leaves the plane where it is held. + let rev = revision(&s); + let mid = held.to_page(Plane::Left, Point::new(3.5 * g.cell, 2.5 * g.cell)).unwrap(); + let r = s.execute("perspective.plane.infer", &json!({"at": [mid.x, mid.y], "tolerance": 0.5})).unwrap(); + assert!(r["active"] == json!(true) && r.get("source").is_none() && revision(&s) == rev, "{r}"); + // With no active plane nothing is held. + s.execute("perspective.plane.set", &json!({"plane": "none"})).unwrap(); + assert_eq!(grid(&s).inferred, None); + assert!(s.execute("perspective.plane.infer", &json!({"at": [at.x, at.y]})).is_err(), "no active plane"); + assert_eq!(s.execute("perspective.plane.infer", &json!({})).unwrap()["active"], json!(false)); +} + +#[test] +fn the_two_options_turn_the_targets_on_and_off_and_are_kept() { + let (mut s, id) = persp_session(); + assert!(s.prefs.perspective_widget.anchor_point && s.prefs.perspective_widget.gridline_intersection, "both on by default"); + let (anchor, _) = top_anchor(&s, id); + let infer = |s: &mut Session| s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y], "tolerance": 0.01})); + assert_eq!(infer(&mut s).unwrap()["source"], json!("anchor")); + s.execute("perspective.plane.infer", &json!({"clear": true})).unwrap(); + let r = s.execute("perspective.widget.options", &json!({"anchorPoint": false})).unwrap(); + assert_eq!((r["anchorPoint"].as_bool(), r["gridlineIntersection"].as_bool(), r["show"].as_bool()), (Some(false), Some(true), Some(true))); + assert!(infer(&mut s).unwrap().get("source").is_none(), "the anchor is no target now"); + assert_eq!(grid(&s).inferred, None); + let back: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap(); + assert!(!back.perspective_widget.anchor_point && back.perspective_widget.gridline_intersection); + s.execute("perspective.widget.options", &json!({"gridlineIntersection": false})).unwrap(); + assert!(infer(&mut s).is_err(), "both off"); + for bad in [json!({"anchorPoint": "yes"}), json!({"gridlineIntersection": 1})] { + assert!(s.execute("perspective.widget.options", &bad).is_err(), "{bad}"); + } + assert!(!s.prefs.perspective_widget.anchor_point, "a refused value changes nothing"); + s.execute("prefs.set", &json!({"key": "perspectiveWidget", "value": {"anchorPoint": true}})).unwrap(); + assert!(s.prefs.perspective_widget.anchor_point && !s.prefs.perspective_widget.gridline_intersection); + // Preferences saved before the options existed read as both on. + let old: vectorcraft_tools::distort::perspective::widget::WidgetOptions = serde_json::from_value(json!({"show": false})).unwrap(); + assert!(old.anchor_point && old.gridline_intersection && !old.show); +} + +#[test] +fn bad_params_are_refused_and_leave_the_grid_as_it_is() { + let (mut s, id) = persp_session(); + let (anchor, _) = top_anchor(&s, id); + let rev = revision(&s); + for bad in [ + json!({"at": "here"}), + json!({"at": [1]}), + json!({"at": [anchor.x, anchor.y], "tolerance": -1}), + json!({"at": [anchor.x, anchor.y], "tolerance": "wide"}), + json!({"at": [anchor.x, anchor.y], "clear": true}), + json!({"clear": "yes"}), + ] { + assert!(s.execute("perspective.plane.infer", &bad).is_err(), "{bad}"); + } + assert_eq!((grid(&s).inferred, revision(&s)), (None, rev)); + // Clearing when nothing is held changes nothing. + assert_eq!(s.execute("perspective.plane.infer", &json!({"clear": true})).unwrap()["active"], json!(false)); + assert_eq!(revision(&s), rev); + s.execute("perspective.grid.show", &json!({"visible": false})).unwrap(); + assert!(s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y]})).is_err(), "hidden grid"); +} + +/// A session whose ground plane is held at the height of the attached rectangle's top anchor. +fn held_session() -> (Session, NodeId, f64) { + let (mut s, id) = persp_session(); + let (anchor, height) = top_anchor(&s, id); + s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap(); + let r = s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y], "tolerance": 0.01})).unwrap(); + assert_eq!(r["source"], json!("anchor")); + (s, id, height) +} + +#[test] +fn attaching_an_object_ends_it_and_the_object_sits_where_the_plane_was_held() { + let (mut s, _, height) = held_session(); + let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap(); + let flat = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [flat.0]})).unwrap(); + let (plane, depth) = attached(&s, flat).unwrap(); + assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}"); + assert_eq!((grid(&s).inferred, grid(&s).ground_offset), (None, 0.0)); + // An attach of nothing, or to a plane it doesn't hold, leaves it held. + let (mut s, ..) = held_session(); + s.execute("perspective.attach", &json!({"ids": []})).unwrap(); + assert!(grid(&s).inferred.is_some()); + let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap(); + let other = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [other.0], "plane": "right"})).unwrap(); + assert_eq!(attached(&s, other).map(|a| a.0), Some(Plane::Right)); + assert!(grid(&s).inferred.is_some()); + // A move of an object already in perspective leaves it held; one brought into perspective ends it. + let (mut s, id, height) = held_session(); + s.execute("perspective.move", &json!({"ids": [id.0], "from": [460, 400], "to": [470, 400]})).unwrap(); + assert!(grid(&s).inferred.is_some()); + let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap(); + let flat = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.move", &json!({"ids": [flat.0], "from": [430, 410], "to": [440, 410]})).unwrap(); + let (plane, depth) = attached(&s, flat).unwrap(); + assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}"); + assert_eq!(grid(&s).inferred, None); +} + +#[test] +fn moving_a_plane_hiding_the_grid_and_reopening_the_file_end_it() { + // A moved plane moves from where it is held and is placed there for good, in one undo step; + // undoing the move holds it again. + let (mut s, _, height) = held_session(); + let steps = undo_len(&s); + s.execute("perspective.plane.move", &json!({"plane": "ground", "by": 10})).unwrap(); + let g = grid(&s); + assert_eq!((g.inferred, undo_len(&s)), (None, steps + 1)); + assert!((g.ground_offset - (height + 10.0)).abs() < 1e-6, "{}", g.ground_offset); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((grid(&s).inferred.is_some(), grid(&s).ground_offset), (true, 0.0)); + // A refused move leaves it held. + let (mut s, ..) = held_session(); + assert!(s.execute("perspective.plane.move", &json!({"plane": "ground", "offset": 1, "objects": "all"})).is_err()); + assert!(grid(&s).inferred.is_some()); + // A plane dragged while it is held, then the drag canceled, is held again. + let (mut s, ..) = held_session(); + s.begin_interaction("Move Plane").unwrap(); + s.preview("perspective.plane.move", &json!({"plane": "ground", "by": 20})).unwrap(); + assert_eq!(grid(&s).inferred, None); + s.cancel_interaction().unwrap(); + assert!(grid(&s).inferred.is_some()); + // Move Plane to Match Object. + let (mut s, id, _) = held_session(); + s.execute("perspective.plane.matchObject", &json!({"id": id.0})).unwrap(); + assert_eq!((grid(&s).inferred, grid(&s).plane), (None, Plane::Right)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((grid(&s).inferred.is_some(), grid(&s).plane), (true, Plane::Ground), "undo holds it again"); + // With the plane placed at the object already, matching only puts the held plane back there. + s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap(); + assert!(grid(&s).inferred.is_some()); + let steps = undo_len(&s); + s.execute("perspective.plane.matchObject", &json!({"id": id.0})).unwrap(); + assert_eq!((grid(&s).inferred, grid(&s).offset(Plane::Right), undo_len(&s)), (None, 0.0, steps)); + // Hide Grid. + let (mut s, ..) = held_session(); + s.execute("perspective.grid.show", &json!({"visible": false})).unwrap(); + assert_eq!(grid(&s).inferred, None); + // The grid's definition changing leaves it held, and it is not `perspective.grid.set`'s to set. + let (mut s, ..) = held_session(); + let held = grid(&s).inferred; + s.execute("perspective.grid.set", &json!({"cell": 31, "inferred": [1, 2, 3]})).unwrap(); + assert_eq!(grid(&s).inferred, held); + // `perspective.grid.set` hiding the grid or choosing no plane ends it. + for patch in [json!({"visible": false}), json!({"plane": "none"})] { + let (mut s, ..) = held_session(); + s.execute("perspective.grid.set", &patch).unwrap(); + assert_eq!(grid(&s).inferred, None, "{patch}"); + } + // Another preset leaves it held, as other changes to the grid's definition do. + let (mut preset, ..) = held_session(); + let before = grid(&preset).inferred; + preset.execute("perspective.grid.preset", &json!({"kind": 3})).unwrap(); + assert_eq!((grid(&preset).kind, grid(&preset).inferred), (3, before)); + // A file saved while a plane is held opens with the plane in its place. + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&saved).unwrap(); + assert!(PerspectiveGrid::positioning(&back), "the saved file still holds the point"); + s.add_document(back, None); + assert_eq!(grid(&s).inferred, None); + assert_eq!(grid(&s).cell, 31.0); +} diff --git a/crates/engine/src/tests/perspgrid.rs b/crates/engine/src/tests/perspgrid.rs index dce149e98c..4b7c1c2ae4 100644 --- a/crates/engine/src/tests/perspgrid.rs +++ b/crates/engine/src/tests/perspgrid.rs @@ -520,7 +520,10 @@ fn the_plane_widget_stays_on_screen_and_takes_clicks_with_any_tool() { click(&mut s, q, view); assert_eq!(grid(&s).plane, Plane::Ground); // Perspective Grid Options move it to another corner or hide it. - assert_eq!(run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})), json!({"show": true, "position": "bottomRight"})); + assert_eq!( + run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})), + json!({"show": true, "position": "bottomRight", "anchorPoint": true, "gridlineIntersection": true}) + ); let r = face(&s, view, WidgetCorner::BottomRight, Plane::Left); assert!(r.x > 100.0 + 450.0 && r.y > 50.0 + 300.0, "{r:?}"); click(&mut s, r, view); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index bb29b06e75..d33ad587fe 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -429,6 +429,7 @@ impl Session { }, screen: view.screen, plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position), + plane_targets: self.prefs.perspective_widget.targets(), precise_cursors: self.precise_cursors(), global_edit: st.global_edit.as_ref().map_or(&[], |g| g.members.as_slice()), }; @@ -1543,7 +1544,7 @@ impl Session { if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) { return None; } - let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone()); + let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone(), it.plane_placed); (st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields) }; let (overlays, transform_box) = if self.preview(cmd, p).is_ok() { @@ -1565,6 +1566,7 @@ impl Session { it.preview = interaction_fields.0; it.copies = interaction_fields.1; it.perspective_again = interaction_fields.2; + it.plane_placed = interaction_fields.3; } } self.pending_paint = paint; diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index e6db6069c7..1e8f57ffc9 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -329,6 +329,8 @@ pub struct ToolContext<'a> { pub screen: Option, /// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden. pub plane_widget: Option, + /// What Automatic Plane Positioning takes a point from (Perspective Grid Options). + pub plane_targets: distort::perspective::widget::PlaneTargets, /// General › Use Precise Cursors, or Caps Lock while it is on: the drawing tools' pointers are /// crosshairs ([`crate::Cursor::precise`]), and the Blob Brush and the Eraser show no tip outline. pub precise_cursors: bool, diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 2e9ecb558b..72cb02b6db 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -26,6 +26,7 @@ use crate::bbox::{Handle as BoxHandle, scale_for_drag}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; pub mod define; +pub mod infer; pub mod view; pub mod widget; pub use define::{GridDefinition, Rgb, Station}; @@ -183,6 +184,11 @@ pub struct PerspectiveGrid { /// Horizontal extent of the right plane (points, plane units); None: `extent`, as the left. #[serde(default, skip_serializing_if = "Option::is_none")] pub extent_right: Option, + /// Automatic Plane Positioning: the point (x, y and z in the grid's frame, points, as + /// [`Axis`] names them) the active plane passes through for now, while the other planes hide + /// ([`infer`]). View state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inferred: Option<[f64; 3]>, } impl PerspectiveGrid { @@ -224,11 +230,18 @@ impl PerspectiveGrid { snap: true, rulers: false, extent_right: None, + inferred: None, } } - /// Where `plane` is along its normal (points; 0: its place in the grid's definition). + /// Where `plane` is along its normal (points; 0: its place in the grid's definition): for the + /// active plane held by Automatic Plane Positioning, where that holds it. pub fn offset(&self, plane: Plane) -> f64 { + self.inferred_offset(plane).unwrap_or_else(|| self.placed_offset(plane)) + } + + /// Where `plane` was moved along its normal, Automatic Plane Positioning aside. + pub fn placed_offset(&self, plane: Plane) -> f64 { match plane { Plane::Left => self.left_offset, Plane::Right => self.right_offset, @@ -325,7 +338,8 @@ impl PerspectiveGrid { let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?; if let (Some(o), Some(p)) = (v.as_object_mut(), patch.as_object()) { for (k, val) in p { - if k == "ids" || k == "attached" { + // Automatic Plane Positioning's point is `perspective.plane.infer`'s to set. + if k == "ids" || k == "attached" || k == "inferred" { continue; } o.insert(k.clone(), val.clone()); @@ -333,6 +347,7 @@ impl PerspectiveGrid { } let mut g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; g.reconcile(self, patch); + g.keep_positioning_valid(); g.validate()?; Ok(g) } @@ -354,7 +369,7 @@ impl PerspectiveGrid { self.extent, self.height, ]; - if nums.iter().any(|v| !v.is_finite() || v.abs() > 4.0e6) { + if nums.iter().chain(self.inferred.iter().flatten()).any(|v| !v.is_finite() || v.abs() > 4.0e6) { return Err("grid values must be finite".into()); } if self.distance <= 0.0 || self.cell <= 0.0 || self.extent <= 0.0 || self.height <= 0.0 { @@ -563,10 +578,31 @@ impl PerspectiveGrid { let Some(h) = self.homography(plane) else { return vec![] }; let d = self.domain(plane); let mut out = vec![]; + let (w, h_len) = (d.width(), d.height()); + let Some(((su, nu), (sv, nv))) = self.line_steps(plane) else { return out }; + for i in 0..=nu { + let u = i as f64 * su; + if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, h_len))) { + out.push((a, b)); + } + } + for i in 0..=nv { + let v = i as f64 * sv; + if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(w, v))) { + out.push((a, b)); + } + } + out + } + + /// How the gridlines of `plane` are spaced as drawn: (step, the number of the last line) along + /// its u and its v axis. `None` for a grid without lines. + pub(crate) fn line_steps(&self, plane: Plane) -> Option<((f64, usize), (f64, usize))> { + let d = self.domain(plane); // A stored grid is not checked: no spacing, or an extent that isn't a number, has no lines. let (w, h_len) = (d.width(), d.height()); if !(self.cell.is_finite() && self.cell > 0.0 && w.is_finite() && h_len.is_finite()) { - return out; + return None; } let step = |len: f64| { let mut s = self.cell; @@ -580,19 +616,7 @@ impl PerspectiveGrid { // tolerance can inflate the count when the whole domain is far smaller than it, so the // bound is capped on its own, before any loop runs. let count = |len: f64, s: f64| ((len + 1e-9) / s).floor().clamp(0.0, 120.0) as usize; - for i in 0..=count(w, su) { - let u = i as f64 * su; - if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, h_len))) { - out.push((a, b)); - } - } - for i in 0..=count(h_len, sv) { - let v = i as f64 * sv; - if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(w, v))) { - out.push((a, b)); - } - } - out + Some(((su, count(w, su)), (sv, count(h_len, sv)))) } /// Plane Switching widget geometry in the first artboard's top-left corner (headless): (plane, @@ -611,10 +635,10 @@ impl PerspectiveGrid { } /// The left, right and horizontal plane widgets: where each sits (on its plane: the middle of a - /// wall's ground edge, the middle of the horizontal plane). + /// wall's ground edge, the middle of the horizontal plane). A hidden plane has none. pub fn plane_widgets(&self) -> Vec<(Plane, Point)> { let e = self.extent / 2.0; - Plane::ALL + self.shown_planes() .into_iter() .filter_map(|pl| { let q = if pl == Plane::Ground { Point::new(e, e) } else { Point::new(e, 0.0) }; @@ -793,7 +817,7 @@ pub fn grid_overlays_in(doc: &Document, tol: f64, place: Option &'static str { + match self { + Self::Anchor => "anchor", + Self::Gridline => "gridline", + } + } +} + +impl Axis { + /// Where the axis' coordinate is in a point of the grid's frame. + fn index(self) -> usize { + match self { + Self::X => 0, + Self::Y => 1, + Self::Z => 2, + } + } +} + +/// Call `f` with each shown object in perspective that isn't inside another one, its plane and +/// its depth: its own record, else the one grid `g` stored before attachments were kept on the +/// objects ([`PerspectiveGrid::attachment_of`]). +fn each_attached<'a>(g: &PerspectiveGrid, n: &'a Node, f: &mut dyn FnMut(&'a Node, Plane, f64)) { + if !n.visible { + return; + } + match attachment(n).or_else(|| g.attached_plane(n.id).map(|p| (p, 0.0))) { + Some((pl, depth)) => f(n, pl, depth), + None => n.children().into_iter().flatten().for_each(|c| each_attached(g, c, f)), + } +} + +/// Call `f` with `n` and each node inside it, leaving out hidden nodes and what they hold. +fn each_shown<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { + if !n.visible { + return; + } + f(n); + n.children().into_iter().flatten().for_each(|c| each_shown(c, f)); +} + +impl PerspectiveGrid { + /// Where Automatic Plane Positioning holds `plane`: the active plane only. + pub(super) fn inferred_offset(&self, plane: Plane) -> Option { + let p = self.inferred?; + if plane != self.plane { + return None; + } + let [.., n] = self.axes(plane)?; + Some(p[n.index()]) + } + + /// The planes that show: all three, or only the active one while Automatic Plane Positioning + /// holds it. + pub fn shown_planes(&self) -> Vec { + match self.inferred { + Some(_) if self.plane != Plane::None => vec![self.plane], + _ => Plane::ALL.to_vec(), + } + } + + /// The point of the grid's frame at plane coordinates `q` on `plane` at `depth`. + fn frame_point(&self, plane: Plane, depth: f64, q: Point) -> Option<[f64; 3]> { + let [u, v, n] = self.axes(plane)?; + let mut out = [0.0; 3]; + out[u.index()] = q.x; + out[v.index()] = q.y; + out[n.index()] = depth; + Some(out) + } + + /// The anchor point of shown art in perspective nearest `p` within `tol`: (distance, frame point). + fn anchor_near(&self, doc: &Document, p: Point, tol: f64) -> Option<(f64, [f64; 3])> { + let mut best: Option<(f64, [f64; 3])> = None; + let mut visit = |n: &Node, plane: Plane, depth: f64| { + // The plane's inverse map, worked out only once an anchor is within reach. + let mut hi = None; + each_shown(n, &mut |c| { + let NodeKind::Path { path, .. } = &c.kind else { return }; + for a in path.subpaths.iter().flat_map(|sp| sp.anchors.iter()) { + let d = a.p.distance(p); + if !(d <= tol && best.is_none_or(|b| d < b.0)) { + continue; + } + let inv = hi.get_or_insert_with(|| self.homography_at(plane, depth).and_then(|h| h.inverse())); + if let Some(at) = inv.as_ref().and_then(|m| m.apply(a.p)).and_then(|q| self.frame_point(plane, depth, q)) { + best = Some((d, at)); + } + } + }); + }; + for l in &doc.layers { + each_attached(self, l, &mut visit); + } + best + } + + /// The gridline intersection of a shown plane nearest `p` within `tol`: (distance, frame point). + fn intersection_near(&self, p: Point, tol: f64) -> Option<(f64, [f64; 3])> { + let mut best: Option<(f64, [f64; 3])> = None; + for plane in self.shown_planes() { + let depth = self.offset(plane); + let Some(h) = self.homography_at(plane, depth) else { continue }; + let Some(q) = h.inverse().and_then(|hi| hi.apply(p)) else { continue }; + let Some(((su, nu), (sv, nv))) = self.line_steps(plane) else { continue }; + // The drawn lines nearest the pointer along each axis, and their neighbors: the + // projection skews and shrinks the cells, so the intersection nearest on the page can be + // one over from the nearest in the plane. + let near = |v: f64, step: f64, last: usize| { + let i = (v / step).round().clamp(0.0, last as f64) as i64; + (i - 2..=i + 2).filter(move |k| (0..=last as i64).contains(k)).map(move |k| k as f64 * step) + }; + for (u, v) in near(q.x, su, nu).flat_map(|u| near(q.y, sv, nv).map(move |v| (u, v))) { + let q = Point::new(u, v); + let Some(d) = h.apply(q).map(|at| at.distance(p)) else { continue }; + if d <= tol + && best.is_none_or(|b| d < b.0) + && let Some(at) = self.frame_point(plane, depth, q) + { + best = Some((d, at)); + } + } + } + best + } + + /// What Automatic Plane Positioning finds within `tol` of page point `p`: the nearest anchor + /// point of shown art in perspective, else the nearest gridline intersection of a shown plane, + /// as a point of the grid's frame. `None` while the grid is hidden or has no active plane. + pub fn infer_at(&self, doc: &Document, p: Point, tol: f64, targets: PlaneTargets) -> Option<(InferSource, [f64; 3])> { + if !self.visible || self.plane == Plane::None || !(tol.is_finite() && p.x.is_finite() && p.y.is_finite()) { + return None; + } + let anchor = if targets.anchors { self.anchor_near(doc, p, tol) } else { None }; + let found = match anchor { + Some((_, at)) => Some((InferSource::Anchor, at)), + None if targets.gridlines => self.intersection_near(p, tol).map(|(_, at)| (InferSource::Gridline, at)), + None => None, + }; + found.filter(|(_, at)| at.iter().all(|v| v.is_finite())) + } + + /// Put a plane that Automatic Plane Positioning holds in the grid stored in `doc` back in its + /// place. Returns whether one was held. + pub fn end_positioning(doc: &mut Document) -> bool { + if !Self::positioning(doc) { + return false; + } + doc.unknown.get_mut(DOC_KEY).and_then(|v| v.as_object_mut()).is_some_and(|o| o.remove("inferred").is_some()) + } + + /// Drop the point Automatic Plane Positioning holds when the grid no longer shows, has no + /// active plane, or would put the active plane behind the viewer through it. + pub fn keep_positioning_valid(&mut self) { + if self.inferred.is_some() && (!self.visible || self.plane == Plane::None || self.validate().is_err()) { + self.inferred = None; + } + } + + /// Whether Automatic Plane Positioning holds a plane of the grid stored in `doc`. + pub fn positioning(doc: &Document) -> bool { + doc.unknown.get(DOC_KEY).and_then(|v| v.get("inferred")).is_some_and(|v| !v.is_null()) + } +} + +/// The Perspective Selection tool's step for Shift held over `p` with no button down: the command +/// that moves the active plane through the anchor point or gridline intersection there, unless +/// the plane is held through that point already. +pub(super) fn hover_action(cx: &ToolContext, g: &PerspectiveGrid, p: Point) -> Option { + let tol = cx.tol(HIT_PX); + let (_, at) = g.infer_at(cx.doc, p, tol, cx.plane_targets)?; + if g.inferred.is_some_and(|held| held.iter().zip(&at).all(|(a, b)| (a - b).abs() <= 1e-9)) { + return None; + } + Some(Action::Exec("perspective.plane.infer".into(), json!({"at": [p.x, p.y], "tolerance": tol}))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use crate::{Mods, PointerEvent, PointerKind, Tool, ToolKey}; + use vectorcraft_doc::Selection; + use vectorcraft_geom::Rect; + + fn grid() -> PerspectiveGrid { + PerspectiveGrid::preset(2, Rect::new(0.0, 0.0, 800.0, 600.0)) + } + + #[test] + fn a_gridline_intersection_gives_the_frame_point_the_planes_pass_through() { + let mut g = grid(); + let (d, cell) = (Document::new(800.0, 600.0), g.cell); + // Three cells along and two up the left wall (two-point: u recedes along Z, v is Y). + let p = g.to_page(Plane::Left, Point::new(3.0 * cell, 2.0 * cell)).unwrap(); + let (source, at) = g.infer_at(&d, p, 1.0, PlaneTargets::ALL).unwrap(); + assert_eq!(source, InferSource::Gridline); + for (got, want) in at.iter().zip([0.0, 2.0 * cell, 3.0 * cell]) { + assert!((got - want).abs() < 1e-6, "{at:?}"); + } + // Held, the active plane lies at the point's coordinate along its normal, the other planes + // hide, and their own places stay. + g.inferred = Some(at); + g.plane = Plane::Ground; + assert!((g.offset(Plane::Ground) - 2.0 * cell).abs() < 1e-6); + assert_eq!((g.placed_offset(Plane::Ground), g.offset(Plane::Left)), (0.0, 0.0)); + assert_eq!(g.shown_planes(), vec![Plane::Ground]); + assert_eq!(g.plane_widgets().len(), 1); + // The plane drawn there passes through the point the pointer was over. + let back = g.to_page(Plane::Ground, Point::new(0.0, 3.0 * cell)).unwrap(); + assert!((back - p).hypot() < 1e-6, "{back:?} {p:?}"); + // Another plane made active passes through it too. + g.plane = Plane::Right; + assert!((g.offset(Plane::Right) - 3.0 * cell).abs() < 1e-6); + assert_eq!(g.offset(Plane::Ground), 0.0); + // With no active plane nothing is held. + g.plane = Plane::None; + assert_eq!(g.shown_planes(), Plane::ALL.to_vec()); + } + + #[test] + fn the_pointer_must_be_within_reach_of_a_target_that_is_on() { + // Only the right wall shows, in its own place: the other planes' lines are out of the way. + let g = PerspectiveGrid { plane: Plane::Right, inferred: Some([0.0; 3]), ..grid() }; + let (d, cell) = (Document::new(800.0, 600.0), g.cell); + let on = g.to_page(Plane::Right, Point::new(2.0 * cell, cell)).unwrap(); + let mid = g.to_page(Plane::Right, Point::new(2.5 * cell, 1.5 * cell)).unwrap(); + assert!(g.infer_at(&d, on + vectorcraft_geom::Vec2::new(2.0, 0.0), 3.0, PlaneTargets::ALL).is_some()); + assert!(g.infer_at(&d, mid, 3.0, PlaneTargets::ALL).is_none(), "the middle of a cell is no intersection"); + assert!(g.infer_at(&d, on, 3.0, PlaneTargets { anchors: true, gridlines: false }).is_none()); + assert!(g.infer_at(&d, on, f64::NAN, PlaneTargets::ALL).is_none()); + assert!(g.infer_at(&d, Point::new(f64::INFINITY, 0.0), 3.0, PlaneTargets::ALL).is_none()); + // Hidden, or without an active plane, the grid gives no point. + assert!(PerspectiveGrid { visible: false, ..g.clone() }.infer_at(&d, on, 3.0, PlaneTargets::ALL).is_none()); + assert!(PerspectiveGrid { plane: Plane::None, ..g.clone() }.infer_at(&d, on, 3.0, PlaneTargets::ALL).is_none()); + // Beyond the plane's last line there is no intersection. + let past = g.to_page(Plane::Right, Point::new(g.right_extent() + 5.0 * cell, cell)).unwrap(); + assert!(g.infer_at(&d, past, 3.0, PlaneTargets::ALL).is_none()); + } + + #[test] + fn a_hidden_path_inside_art_in_perspective_has_no_anchor_to_take() { + let g = PerspectiveGrid { plane: Plane::Right, ..grid() }; + let mut d = Document::new(800.0, 600.0); + g.store(&mut d); + // A group on the right wall holding one small square, a corner in the middle of a cell. + let at = g.to_page(Plane::Right, Point::new(2.5 * g.cell, 1.5 * g.cell)).unwrap(); + let (layer, group, square) = (d.layers[0].id, d.alloc_id(), d.alloc_id()); + let mut path = Node::path(square, vectorcraft_geom::shapes::rectangle(Rect::new(at.x, at.y, at.x + 2.0, at.y + 2.0)), Default::default()); + path.visible = false; + let mut art = Node::group(group, vec![std::sync::Arc::new(path)]); + super::super::set_attachment(&mut art, Plane::Right, 0.0); + d.insert(Some(layer), 0, art).unwrap(); + let anchors = PlaneTargets { anchors: true, gridlines: false }; + assert!(g.infer_at(&d, at, 0.5, anchors).is_none(), "the square is hidden"); + d.node_mut(square).unwrap().visible = true; + let (source, point) = g.infer_at(&d, at, 0.5, anchors).unwrap(); + assert_eq!(source, InferSource::Anchor); + for (got, want) in point.iter().zip([2.5 * g.cell, 1.5 * g.cell, 0.0]) { + assert!((got - want).abs() < 1e-6, "{point:?}"); + } + // A grid stored before attachments were kept on the objects lists them itself. + let mut d = Document::new(800.0, 600.0); + let (layer, square) = (d.layers[0].id, d.alloc_id()); + d.insert( + Some(layer), + 0, + Node::path(square, vectorcraft_geom::shapes::rectangle(Rect::new(at.x, at.y, at.x + 2.0, at.y + 2.0)), Default::default()), + ) + .unwrap(); + assert!(g.infer_at(&d, at, 0.5, anchors).is_none(), "not in perspective"); + let mut legacy = g.clone(); + legacy.attached.insert(square.0.to_string(), Plane::Right); + assert_eq!(legacy.infer_at(&d, at, 0.5, anchors).map(|f| f.0), Some(InferSource::Anchor)); + } + + #[test] + fn the_stored_point_ends_with_end_positioning() { + let mut d = Document::new(800.0, 600.0); + let mut g = grid(); + g.store(&mut d); + assert!(!PerspectiveGrid::positioning(&d)); + assert!(!PerspectiveGrid::end_positioning(&mut d)); + g.inferred = Some([1.0, 2.0, 3.0]); + g.store(&mut d); + assert!(PerspectiveGrid::positioning(&d)); + assert_eq!(PerspectiveGrid::current(&d).inferred, Some([1.0, 2.0, 3.0])); + // `perspective.grid.set` leaves the point as it is. + assert_eq!(g.merged(&json!({"inferred": [9, 9, 9], "cell": 25})).unwrap().inferred, Some([1.0, 2.0, 3.0])); + assert!(PerspectiveGrid { inferred: Some([f64::NAN, 0.0, 0.0]), ..g.clone() }.validate().is_err()); + assert!(PerspectiveGrid::end_positioning(&mut d)); + assert_eq!(PerspectiveGrid::current(&d), PerspectiveGrid { inferred: None, ..g }); + } + + #[test] + fn shift_over_an_intersection_asks_for_the_plane_once() { + let mut d = Document::new(800.0, 600.0); + let mut g = grid(); + g.store(&mut d); + let (s, p) = (Selection::default(), paint()); + let at = g.to_page(Plane::Left, Point::new(2.0 * g.cell, 3.0 * g.cell)).unwrap(); + let shift = Mods { shift: true, ..Default::default() }; + let hover = |d: &Document, mods: Mods| { + let c = cx(d, &s, &p); + let mut t = super::super::PerspectiveSelectionTool::default(); + t.pointer(&c, &PointerEvent { mods, ..PointerEvent::new(PointerKind::Move, at.x, at.y) }) + }; + assert!(hover(&d, Mods::default()).is_empty(), "without Shift the pointer only hovers"); + let acts = hover(&d, shift); + let [Action::Exec(cmd, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((cmd.as_str(), &v["at"]), ("perspective.plane.infer", &json!([at.x, at.y]))); + // Held through that point already, the hover asks for nothing more. + g.inferred = g.infer_at(&d, at, 1.0, PlaneTargets::ALL).map(|f| f.1); + g.store(&mut d); + assert!(hover(&d, shift).is_empty()); + // Escape puts the plane back. + let c = cx(&d, &s, &p); + let mut t = super::super::PerspectiveSelectionTool::default(); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Exec("perspective.plane.infer".into(), json!({"clear": true}))]); + } +} diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index 915e4a8835..1716a4593d 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -289,6 +289,9 @@ impl Tool for PerspectiveSelectionTool { _ => vec![], } } + // Automatic Plane Positioning: Shift held over an anchor point of art in perspective or + // over a gridline intersection, with no button down. During a drag Shift constrains. + (PointerKind::Move, SelDrag::Idle) if m.shift => super::infer::hover_action(cx, &g, p).into_iter().collect(), (_, d) => { self.drag = d; vec![] @@ -296,7 +299,8 @@ impl Tool for PerspectiveSelectionTool { } } /// Escape during a move, a resize or a plane widget drag cancels it: the objects and the plane - /// go back to the start of the drag. + /// go back to the start of the drag. With no drag, Escape puts a plane that Automatic Plane + /// Positioning holds back in its place. fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { if key == ToolKey::Escape && self.busy() { self.measure = None; @@ -305,6 +309,9 @@ impl Tool for PerspectiveSelectionTool { _ => vec![Action::Cancel], }; } + if key == ToolKey::Escape && PerspectiveGrid::current(cx.doc).inferred.is_some() { + return vec![Action::Exec("perspective.plane.infer".into(), json!({"clear": true}))]; + } if let (ToolKey::Digit(5), SelDrag::Move { perp, began, shift, .. }) = (key, &mut self.drag) { *perp = !*perp; *shift = mods.shift; diff --git a/crates/tools/src/distort/perspective/widget.rs b/crates/tools/src/distort/perspective/widget.rs index 4af1173e83..c5bdf05542 100644 --- a/crates/tools/src/distort/perspective/widget.rs +++ b/crates/tools/src/distort/perspective/widget.rs @@ -50,17 +50,44 @@ impl WidgetCorner { } } -/// Perspective Grid Options: whether the Plane Switching Widget shows, and where. +/// Perspective Grid Options: whether the Plane Switching Widget shows, and where, and what +/// Automatic Plane Positioning takes a point from ([`super::infer`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct WidgetOptions { pub show: bool, pub position: WidgetCorner, + /// Automatic Plane Positioning › Anchor Point of Perspective Artwork. + pub anchor_point: bool, + /// Automatic Plane Positioning › Intersection of Gridlines. + pub gridline_intersection: bool, } impl Default for WidgetOptions { fn default() -> Self { - Self { show: true, position: WidgetCorner::TopLeft } + Self { show: true, position: WidgetCorner::TopLeft, anchor_point: true, gridline_intersection: true } + } +} + +impl WidgetOptions { + /// What Automatic Plane Positioning takes a point from. + pub fn targets(self) -> PlaneTargets { + PlaneTargets { anchors: self.anchor_point, gridlines: self.gridline_intersection } + } +} + +/// What Automatic Plane Positioning takes a point from: the anchor points of art in perspective, +/// the gridline intersections of the planes that show. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PlaneTargets { + pub anchors: bool, + pub gridlines: bool, +} + +impl PlaneTargets { + pub const ALL: Self = Self { anchors: true, gridlines: true }; + pub fn any(self) -> bool { + self.anchors || self.gridlines } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 1119bb0764..8922eedb5f 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -604,6 +604,7 @@ pub(crate) mod testutil { construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES), screen: None, plane_widget: Some(Default::default()), + plane_targets: distort::perspective::widget::PlaneTargets::ALL, precise_cursors: false, global_edit: &[], } diff --git a/crates/ui-egui/src/dialogs/perspective_options.rs b/crates/ui-egui/src/dialogs/perspective_options.rs index 91e74de0e2..901023fabc 100644 --- a/crates/ui-egui/src/dialogs/perspective_options.rs +++ b/crates/ui-egui/src/dialogs/perspective_options.rs @@ -1,8 +1,9 @@ //! Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane Switching -//! Widget shows and which corner of the window it sits in. OK runs `perspective.widget.options` -//! (the options live in the preferences). +//! Widget shows and which corner of the window it sits in, and what Automatic Plane Positioning +//! takes a point from. OK runs `perspective.widget.options` (the options live in the preferences). //! -//! Fields: `show` (bool) and `position` (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`). +//! Fields: `show` (bool), `position` (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`), +//! `anchorPoint` (bool) and `gridlineIntersection` (bool). use serde_json::{Value, json}; use vectorcraft_tools::distort::perspective::widget::WidgetCorner; @@ -38,5 +39,13 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_space(8.0); let corners: Vec<(&str, &str)> = WidgetCorner::ALL.iter().map(|c| (c.id(), c.label())).collect(); ui.add_enabled_ui(on, |ui| form::choice(ui, d, "position", tl!("Widget Position:"), (110.0, 160.0), &corners)); + ui.add_space(8.0); + widgets::section_header(ui, tl!("Automatic Plane Positioning")); + for (key, label) in [("anchorPoint", tl!("Anchor Point of Perspective Artwork")), ("gridlineIntersection", tl!("Intersection of Gridlines"))] { + let on = d.bool(key); + if widgets::check(ui, label, on, true) { + d.fields.insert(key.into(), json!(!on)); + } + } false } diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index 97255bd22d..38aaf67770 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -250,6 +250,26 @@ fn double_clicking_the_tool_opens_perspective_grid_options() { assert!(!app.session.prefs.perspective_widget.show); } +#[test] +fn perspective_grid_options_hold_automatic_plane_positioning() { + let mut app = app(); + app.run("tool.options", json!({"tool": "perspectiveGrid"})).unwrap(); + assert_eq!((field(&app, "anchorPoint"), field(&app, "gridlineIntersection")), (json!(true), json!(true)), "both on by default"); + frame(&mut app); + set(&mut app, "gridlineIntersection", json!(false)); + frame(&mut app); + super::confirm(&mut app).unwrap(); + assert_eq!(kind(&app), None); + let o = app.session.prefs.perspective_widget; + assert!(o.anchor_point && !o.gridline_intersection && o.show); + // The dialog opens on what was set, and a value that is not true or false is refused. + app.run("tool.options", json!({"tool": "perspectiveGrid"})).unwrap(); + assert_eq!(field(&app, "gridlineIntersection"), json!(false)); + set(&mut app, "anchorPoint", json!("off")); + assert!(super::confirm(&mut app).is_err()); + assert!(app.session.prefs.perspective_widget.anchor_point); +} + #[test] fn digit_keys_switch_planes_and_the_widget_follows_the_window() { let mut app = app(); diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..0418f6344f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10217,9 +10217,30 @@ or of a plane widget, cancels it and puts the grid back. The Plane Switching Wid of the document window (headless: the first artboard's top-left corner); a press on it picks the plane with any tool while the grid shows (choosing a perspective tool shows it, and `perspective.grid.show` hides it again with the tool still chosen), and the keys 1–4 (`key` with `"1"`…`"4"`) pick the left, horizontal, right and no plane. -`perspective.widget.options {show?, position?}` (Perspective Grid Options, double-click the tool) hides it or moves -it to `topLeft`, `topRight`, `bottomLeft` or `bottomRight`, kept with the preferences (`prefs.get`/`prefs.set` key -`perspectiveWidget`). +`perspective.widget.options {show?, position?, anchorPoint?, gridlineIntersection?}` (Perspective Grid Options, +double-click the tool) hides it or moves it to `topLeft`, `topRight`, `bottomLeft` or `bottomRight`, and turns the +two targets of Automatic Plane Positioning on or off (`anchorPoint`: Anchor Point of Perspective Artwork; +`gridlineIntersection`: Intersection of Gridlines; both on by default). The options are kept with the preferences +(`prefs.get`/`prefs.set` key `perspectiveWidget`), and a value that is not `true` or `false` is an error. + +Automatic Plane Positioning: `perspective.plane.infer {at?: [x, y], tolerance?, clear?}` moves the active plane +through the anchor point of shown art in perspective, or the gridline intersection of a plane that shows, within +`tolerance` points of `at` (6 by default), for now, and hides the other planes. An anchor point within reach wins +over an intersection. With the Perspective Selection tool, `pointer` `move` with Shift over one does the same, +within 6 screen pixels; with a button down, Shift constrains the move as before. `perspective.plane.set` then brings +the plane it makes active through the same point. The plane goes back to its place, and the other planes show +again, when an object is drawn on it or attached to it (`perspective.draw`, `perspective.attach`, or an object +brought into perspective by `perspective.move` or `perspective.transform`; a drag keeps the plane held until it is +committed), when `perspective.plane.matchObject` moves a plane, when the grid hides, when no plane is active, when +the file is opened again, and with `clear` (Escape with the Perspective Selection tool). `perspective.plane.move` +moves the held plane from where it is held and leaves it at its new place, and the other planes show again. +The held point is view state, not an undo step: `perspective.grid.get` reports it as `grid.inferred` (x, y and z +in the grid's frame). `perspective.grid.set`, `perspective.grid.define` and `perspective.grid.preset` keep it +unless the grid they make hides, has no active plane, or would put the plane through the point behind the viewer. +Without params the command reads the state: +`{active, plane, offset, point?, source?: anchor|gridline}`. With `at`, `source` is absent when nothing was within +reach, and the grid stays as it was. It is an error when the grid is hidden, when no plane is active, when both +targets are off, and when `at`, `tolerance` or `clear` is not what the params take. ## Envelopes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..e20a71a828 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1900,6 +1900,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing. Without Shift, on each axis, the edge of their joint bounds nearer a gridline lands on it when that edge ends within a quarter cell of the gridline. With Shift, an edge also lands only when it ends within a quarter cell of a gridline: a horizontal or vertical move can land only an edge on its own axis, and a 45° move lands the edge on the axis whose nonzero correction is smaller. When no edge on those axes is that close, the move does not snap. A move perpendicular to the plane does not snap. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. + Perspective Grid Options has Automatic Plane Positioning with its two checkboxes, Anchor Point of Perspective Artwork and Intersection of Gridlines. With the Perspective Selection tool, Shift held over an anchor point of art in perspective, or over a gridline intersection, moves the active plane to that height or depth and hides the other planes; a plane made active then passes through the same point, and the planes are back in place once an object is drawn or placed there (`perspective.plane.infer`). VectorCraft's own rules: both options are on by default and kept with the preferences, the pointer counts as over a target within 6 screen pixels, an anchor point wins over an intersection, and Shift positions the plane only while no button is down. Tests: `crates/engine/src/tests/persp_infer.rs`. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. From dab13fbf141fba5f6e95b1ae4e167a53ff726e7b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:33:51 -0800 Subject: [PATCH 61/82] Import PDF: Import PDF pages as links opens each page linked to its file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open's Import PDF dialog gets Import PDF pages as links. With it on, each page picked opens as a placed document linked to that page of the file, on an artboard of its Crop To box and a layer named after the page, in place of editable art. The Links panel lists each page, and Embed makes a page editable. document.open takes the matching linkPages param. It is refused for a file that is not a PDF or a PDF-compatible .ai, for bytes without a path, with a password and with textAs: outlines. The pages share one copy of the file, and the document opens in CMYK when most of its pages do. Save asks for a name, as for other partly read PDFs. --- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 19 +++- crates/engine/src/cmd/fileio/pdfimport.rs | 101 ++++++++++++++++-- crates/engine/src/cmd/place/document.rs | 44 +++++++- crates/engine/src/tests/placed_document.rs | 94 ++++++++++++++++ crates/ui-egui/src/dialogs/import_pdf.rs | 22 +++- .../ui-egui/src/dialogs/tests_import_pdf.rs | 41 +++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/gu.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 2 +- docs/mcp.md | 10 ++ 37 files changed, 347 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ca74243699..b7eb9ddd96 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index bb9dc3711a..e06b24c4b9 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -677,15 +677,28 @@ impl ReadFile { /// ([`super::importer`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let mut loaded = match super::importer::pick(name, bytes, p)? { - Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, - None => load_with(name, bytes, &opts)?, + let mut loaded = if opts.link_pages { + linked_pages(name, bytes, path.as_deref(), &opts)? + } else { + match super::importer::pick(name, bytes, p)? { + Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, + None => load_with(name, bytes, &opts)?, + } }; let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) } +/// The PDF `name` (`bytes`, read from `path`) opened with `linkPages`: its pages as placed documents +/// linked to them ([`super::pdfimport::linked_pages`]), named after the file. +fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, opts: &LoadOptions) -> Result { + let (mut doc, warnings) = super::pdfimport::linked_pages(name, bytes, path, opts)?; + doc.title = file_name(name); + let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{}`: not a PDF", file_name(name))))?; + Ok(Loaded { doc, format, warnings, restored: false, converted: false, preview_only: false, editing_data: false, native_pdf_compatible: None }) +} + /// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active /// document → `document.open`'s result. pub fn open_read(s: &mut Session, file: ReadFile) -> Result { diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 2a0dede17f..b0f08ca4c5 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -2,11 +2,15 @@ //! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages, //! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog. +use std::sync::Arc; + use serde_json::{Value, json}; -use vectorcraft_doc::{ColorMode, Document}; +use vectorcraft_doc::{Artboard, ColorMode, Document, LayerColor, LinkInfo, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs}; use super::super::colormgmt::Grays; +use super::super::place::document; use super::super::{Result, Session, bad, check_numbers, number_or, parse_range, text_param}; use super::{err, source}; @@ -47,6 +51,9 @@ pub struct LoadOptions { /// A PDF or `.ai` saved with Preserve Editing opens from the VectorCraft document it carries; /// off, it opens as another app would open it, from the editing copy of its art or its pages. pub native: bool, + /// Each page of a PDF opens as a placed document linked to that page of its file, on an + /// artboard of its own, instead of as editable art (`linkPages`; see [`linked_pages`]). + pub link_pages: bool, } impl Default for LoadOptions { @@ -65,6 +72,7 @@ impl Default for LoadOptions { trust_editing_data: false, legacy_names: vectorcraft_eps::NameEncoding::default(), native: true, + link_pages: false, } } } @@ -74,8 +82,8 @@ impl LoadOptions { /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`, /// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false), /// `editingData` (true | false | `always`), - /// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF - /// import options). + /// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]), `linkPages` (true | + /// false) and `dxf` (the DXF import options). pub fn from_params(cmd: &str, p: &Value) -> Result { let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) { None => None, @@ -115,6 +123,10 @@ impl LoadOptions { .and_then(vectorcraft_eps::NameEncoding::from_label) .ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?, }; + let link_pages = match p.get("linkPages").filter(|v| !v.is_null()) { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "linkPages must be true or false"))?, + }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; Ok(Self { pages, @@ -130,14 +142,15 @@ impl LoadOptions { trust_editing_data, legacy_names, native: true, + link_pages, }) } /// Does the document read only part of the file, or read it differently (a page range, another - /// box, a password, without its hidden layers, without the VectorCraft document it carries)? - /// Writing it back would lose the rest, so Save doesn't (it asks for a name). + /// box, a password, without its hidden layers, without the VectorCraft document it carries, its + /// pages linked)? Writing it back would lose the rest, so Save doesn't (it asks for a name). pub fn is_partial(&self) -> bool { - self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native + self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native || self.link_pages } /// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible @@ -201,6 +214,82 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions { ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: o.text_as, layers: false, ..Default::default() } } +/// `document.open` with `linkPages`: the pages `o` picks of the PDF (or PDF-compatible `.ai` or +/// `.ait`) `name`, read from `path`, as a document holding one placed document per page, linked to +/// that page of the file as File › Place with Link places one ([`document::source`]). Each page +/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`), in a row from the left +/// with the gap a PDF's artboards open with, and a layer named after the page. The document opens +/// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a +/// path, for any other file, with a password (a linked page is read again without one), with +/// `textAs: outlines` (a linked page shows the file's own type) and for a file over +/// [`document::MAX_FILE`] → the document and the notes of reading its pages. +pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &LoadOptions) -> Result<(Document, Vec)> { + const C: &str = "document.open"; + let file = super::file_name(name); + if !document::is_pdf(name, bytes) { + return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file"))); + } + let path = path.ok_or_else(|| bad(C, "linkPages needs the file's path: linked pages are read from it"))?; + if o.password.is_some() { + return Err(bad(C, "linkPages can't open a file with a password: linked pages are read again without it")); + } + if o.text_as != TextAs::Text { + return Err(bad(C, "linkPages can't be used with textAs: outlines: linked pages show the file's own type")); + } + if bytes.len() > document::MAX_FILE { + return Err(bad(C, format!("linkPages: `{file}` is over {} MB", document::MAX_FILE >> 20))); + } + let count = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?.page_count(); + let picked = match &o.pages { + None => (0..count).collect(), + Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?, + }; + let bounding = o.crop == CropTo::Bounding; + let mut doc = Document::new(1.0, 1.0); + let board = doc.artboards.first().cloned().ok_or_else(|| err("a new document has no artboard"))?; + doc.artboards.clear(); + doc.layers.clear(); + let link = crate::cmd::links::link_info(path, bytes); + let gap = ImportOptions::default().artboard_gap; + // Every page's object keeps the same one copy of the file. + let shared = Arc::new(bytes.to_vec()); + let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::::new(), 0); + for (i, &index) in picked.iter().enumerate() { + let page = index as u32 + 1; + let s = document::shared_source(name, &shared, Some(path), page, bounding, document::page_box(o.crop), C)?; + cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk); + let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding); + if let NodeKind::PlacedDocument(p) = &mut node.kind { + p.xf = Affine::translate((x, 0.0)); + } + node.name = Some(file.clone()); + let mut layer = Node::layer(doc.alloc_id(), &format!("Page {page}"), LayerColor::Preset((i % 27) as u8)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + children.push(Arc::new(node)); + } + doc.layers.push(Arc::new(layer)); + let rect = Rect::new(x, 0.0, x + s.frame.width(), s.frame.height()); + doc.artboards.push(Artboard { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, ..board.clone() }); + x = rect.x1 + gap; + for w in s.warnings { + if !warnings.contains(&w) { + warnings.push(w); + } + } + } + // Pages painted mostly in CMYK read as CMYK documents, as opening them as art does: the + // document opens in CMYK (with the CMYK default swatches) when most of its pages do. + if cmyk_pages * 2 > picked.len() { + doc.color_mode = ColorMode::Cmyk; + let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + } + if let Some(mode) = o.color_mode.filter(|m| *m != doc.color_mode) { + super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, o.grays); + } + Ok((doc, warnings)) +} + const INFO: &str = "document.pdfInfo"; /// `document.pdfInfo`. diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 8b00e95152..5809990e0f 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use vectorcraft_doc::links::hash_bytes; use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for}; -use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{ColorMode, Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions}; use vectorcraft_geom::{Affine, Rect}; use vectorcraft_pdf::CropTo; @@ -221,22 +221,57 @@ pub(crate) struct Source { /// What is shown, in the file's coordinates: the artboard, the page's box or the art's bounds. pub frame: Rect, pub warnings: Vec, + /// The colour mode the file's art was read in (a PDF page painted mostly in CMYK reads in CMYK). + pub color_mode: ColorMode, } /// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing /// artboard `page`, or the PDF or `.ai` file for showing page `page` clipped to its box /// `page_box` (its art's bounds when `bounding`). pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str) -> Result { + read_source(name, bytes, None, path, (page, bounding, page_box), cmd) +} + +/// [`source`] of the file whose bytes `bytes` holds, kept by the blob without a copy of its own: +/// several pages of one file read this way share one copy of it. +pub(crate) fn shared_source( + name: &str, + bytes: &Arc>, + path: Option<&str>, + page: u32, + bounding: bool, + page_box: PageBox, + cmd: &str, +) -> Result { + read_source(name, bytes, Some(bytes), path, (page, bounding, page_box), cmd) +} + +/// [`source`], the blob keeping `shared` when given (else a copy of `bytes`); `shown` is the page +/// (or artboard), whether the art's bounds are shown, and the page's box. +fn read_source( + name: &str, + bytes: &[u8], + shared: Option<&Arc>>, + path: Option<&str>, + shown: (u32, bool, PageBox), + cmd: &str, +) -> Result { + let (page, bounding, page_box) = shown; // The same document placed the same way is read once (placing it again is free). let key = key_for(&hash_bytes(bytes), page, bounding, page_box); // Resolving its links depends on where the file is and on what the caller may read, so a hit // needs the same path, and nothing is shared with or from a confined (automation) caller. let confined = crate::file_access::current().is_some(); - if !confined && let Some((_, s)) = lock_memo().iter().find(|(at, s)| s.key == key && at.as_deref() == path).cloned() { + if !confined && let Some((_, mut s)) = lock_memo().iter().find(|(at, s)| s.key == key && at.as_deref() == path).cloned() { + // The same bytes: the copy given is kept instead, so the pages that share it go on sharing. + if let Some(b) = shared { + s.blob.bytes = b.clone(); + } return Ok(s); } let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?; - let mut blob = ImageBlob::new(mime(name, bytes), bytes.to_vec()); + let bytes = shared.cloned().unwrap_or_else(|| Arc::new(bytes.to_vec())); + let mut blob = ImageBlob { mime: mime(name, &bytes).into(), bytes, proxy: None }; let shown = PlacedDocument { link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) }, key: key.clone(), @@ -249,9 +284,10 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo }; let art = group(a.nodes, a.clip); blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new); + let color_mode = a.doc.color_mode; // Drawing it needn't read it again. vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); - let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings }; + let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode }; if !confined { let mut m = lock_memo(); if m.len() >= MEMO_SIZE { diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index c639fc9232..65808d2360 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -599,6 +599,100 @@ fn with_link_a_pdf_page_places_linked() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// The colour at the centre of `r` in `doc`. +fn colour_at(doc: &Document, r: Rect) -> [u8; 3] { + let img = vectorcraft_render::Renderer::new().render_region(doc, r, 1.0, true); + let [red, g, b, _] = img.pixel((r.width() / 2.0) as u32, (r.height() / 2.0) as u32); + [red, g, b] +} + +#[test] +fn link_pages_opens_each_picked_pdf_page_as_a_linked_placed_document() { + let dir = Folder::new("open-pdf-link-pages"); + let src = dir.file("flyer.pdf"); + pdf_source(&src, "pdf", &[RED, BLUE, RED], 0); + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"path": src, "linkPages": true, "pages": "2-3"})).unwrap(); + assert_eq!((r["format"].as_str(), r["restored"].as_bool()), (Some("pdf"), Some(false)), "{r}"); + let st = s.doc().unwrap(); + assert_eq!(st.path, None, "Save asks for a name instead of writing over the PDF"); + let d = &st.doc; + let boards: Vec = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(136.0, 0.0, 236.0, 50.0)]); + let layers: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(layers, ["Page 2", "Page 3"]); + let mut ids = vec![]; + for (layer, (page, board)) in d.layers.iter().zip([(2, boards[0]), (3, boards[1])]) { + let kids = layer.children().unwrap(); + assert_eq!(kids.len(), 1); + let p = placed(&s, kids[0].id); + assert_eq!((p.link.path.as_str(), p.link.page, p.bounding), (src.as_str(), Some(page), false), "{p:?}"); + assert_eq!(bounds(&s, kids[0].id), board); + ids.push(kids[0].id); + } + assert!(near(colour_at(d, boards[0]), BLUE)); + assert!(near(colour_at(d, boards[1]), RED)); + let links = s.execute("links.list", &json!({})).unwrap()["links"].clone(); + assert_eq!(links.as_array().map(Vec::len), Some(2), "{links}"); + assert!(links.as_array().unwrap().iter().all(|row| row["document"] == true && row["status"] == "ok"), "{links}"); + // Embed makes a page editable art. + s.execute("links.embed", &json!({"ids": [ids[0].0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.node(ids[0]).unwrap().kind_label(), "Clip Group"); + assert_eq!(bounds(&s, ids[0]), boards[0]); + assert!(near(colour_at(d, boards[0]), BLUE)); + // Every page, with the art's bounds as the artboard. + s.execute("document.open", &json!({"path": src, "linkPages": true, "cropTo": "bounding"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards.len(), 3); + assert!(d.layers.iter().flat_map(|l| l.children().unwrap()).all(|n| matches!(&n.kind, NodeKind::PlacedDocument(p) if p.bounding))); +} + +#[test] +fn linked_pages_share_one_copy_of_the_file_and_open_in_the_pages_color_mode() { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + let dir = Folder::new("open-pdf-link-pages-cmyk"); + let src = dir.file("cmyk.pdf"); + let page = || PdfPage::new(100.0, 50.0, "0 0.5 1 0 k 0 0 100 50 re f"); + write(&src, &pdf(&[page(), page(), page()], None)); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": src, "linkPages": true})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.color_mode, vectorcraft_doc::ColorMode::Cmyk, "pages painted in CMYK open as a CMYK document"); + let blobs: Vec<_> = d.images.values().map(|b| b.bytes.clone()).collect(); + assert_eq!(blobs.len(), 3); + assert!(blobs.iter().all(|b| std::sync::Arc::ptr_eq(b, &blobs[0])), "one copy of the file for every page"); + s.execute("document.open", &json!({"path": src, "linkPages": true, "colorMode": "rgb"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb, "colorMode wins"); +} + +#[test] +fn link_pages_is_refused_without_a_path_for_other_files_and_with_a_password_or_outlines() { + let dir = Folder::new("open-pdf-link-pages-refused"); + let src = dir.file("flyer.pdf"); + pdf_source(&src, "pdf", &[RED, BLUE], 0); + let bytes = std::fs::read(&src).unwrap(); + let logo = dir.file("logo.vectorcraft"); + source(&logo, 100.0, 50.0, RED, 0); + let mut s = Session::new(); + let cases = [ + (json!({"name": "flyer.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes), "linkPages": true}), "needs the file's path"), + (json!({"path": logo, "linkPages": true}), "isn't a PDF"), + (json!({"path": src, "linkPages": true, "password": "secret"}), "password"), + (json!({"path": src, "linkPages": true, "textAs": "outlines"}), "textAs: outlines"), + (json!({"path": src, "linkPages": "yes"}), "linkPages must be true or false"), + (json!({"path": src, "linkPages": true, "pages": "3"}), "pages:"), + ]; + for (p, says) in cases { + let e = s.execute("document.open", &p).unwrap_err().to_string(); + assert!(e.contains(says), "{p}: {e}"); + } + assert!(s.documents().is_empty(), "nothing opened"); + // Off, the pages open as editable art. + s.execute("document.open", &json!({"path": src, "linkPages": false})).unwrap(); + assert!(!s.doc().unwrap().doc.has_placed()); +} + #[test] fn a_changed_pdf_is_modified_and_update_links_reads_it_again() { let dir = Folder::new("placed-pdf-update"); diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index 1baa838d90..4820680a09 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -2,7 +2,8 @@ //! asks which pages (a preview with page navigation), the box to crop to and the password. //! //! Fields: `mode` (`open` | `place`), `name`, `path?`, `pageCount`, `page` (the page shown, and the -//! one placed; 1-based), `allPages` and `range` (open), `cropTo` and `password`. `__locked` is set +//! one placed; 1-based), `allPages`, `range` and `linkPages` (open: Import PDF Pages as Links, for +//! a file read from a path and opened without a password), `cropTo` and `password`. `__locked` is set //! until the password is accepted: OK then unlocks the file. `__place` keeps the other `file.place` //! params (Link, Template, `at`…). The file's bytes stay out of the fields, in //! [`crate::state::UiState::dialog_file`]. @@ -142,6 +143,7 @@ pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option Option { Some(d.str("password")).filter(|p| !p.is_empty()) } +/// Can the pages open linked: the file was read from a path (a linked page is read from its +/// file again) and opens without a password (`document.open`'s `linkPages` refuses one). +fn can_link(d: &Dialog) -> bool { + d.fields.get("path").and_then(Value::as_str).is_some() && password(d).is_none() +} + +/// Import PDF Pages as Links is checked, and can be. +fn links_on(d: &Dialog) -> bool { + d.bool("linkPages") && can_link(d) +} + /// OK: unlock the file with the password, else open the pages or place the page. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let file = app.ui.dialog_file.clone().ok_or("the PDF is no longer loaded")?; @@ -213,6 +226,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if !d.bool("allPages") && !range.trim().is_empty() { p["pages"] = json!(range); } + if links_on(&d) { + p["linkPages"] = json!(true); + } io::open_document(app, &name, &file.bytes, path, &p).map(|_| ()) }; match r { @@ -291,6 +307,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); widgets::dim_label(ui, tl!("e.g. 1-3, 5")); + ui.add_space(10.0); + if widgets::check(ui, tl!("Import PDF pages as links"), links_on(d), can_link(d)) { + d.fields.insert("linkPages".into(), json!(!d.bool("linkPages"))); + } } }); }); diff --git a/crates/ui-egui/src/dialogs/tests_import_pdf.rs b/crates/ui-egui/src/dialogs/tests_import_pdf.rs index 0a1f5b5f7e..62e783c267 100644 --- a/crates/ui-egui/src/dialogs/tests_import_pdf.rs +++ b/crates/ui-egui/src/dialogs/tests_import_pdf.rs @@ -83,6 +83,47 @@ fn all_pages_and_a_crop_box() { assert_eq!(artboard_widths(&app), [10.0, 40.0, 30.0]); } +/// Each layer's objects in the active document: placed documents (true) or not. +fn placed_pages(app: &VectorcraftApp) -> Vec { + let doc = &app.session.active().unwrap().doc; + doc.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| matches!(n.kind, NodeKind::PlacedDocument(_))).collect() +} + +#[test] +fn import_pdf_pages_as_links_opens_each_page_linked() { + let dir = vectorcraft_testkit::TempDir::new("import-pdf-link-pages"); + let path = dir.join("three.pdf").to_string_lossy().into_owned(); + std::fs::write(&path, three_pages()).unwrap(); + let mut app = app(); + io::open_bytes(&mut app, &path, &three_pages(), Some(path.clone())).unwrap(); + assert_eq!(field(&app, "linkPages"), false, "off by default"); + frame(&mut app); + set(&mut app, "linkPages", json!(true)); + set(&mut app, "allPages", json!(false)); + set(&mut app, "range", json!("1, 3")); + frame(&mut app); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(artboard_widths(&app), [100.0, 300.0]); + assert_eq!(placed_pages(&app), [true, true]); + // A file given without its path, or opened with a password, can't link its pages: they open as art. + io::open_bytes(&mut app, "three.pdf", &three_pages(), None).unwrap(); + set(&mut app, "linkPages", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(placed_pages(&app), [false, false, false]); + let two = pdf(&[PdfPage::new(10.0, 10.0, "0 g 0 0 5 5 re f"), PdfPage::new(20.0, 10.0, "0 g 0 0 5 5 re f")], Some("pw")); + let locked = dir.join("two.pdf").to_string_lossy().into_owned(); + std::fs::write(&locked, &two).unwrap(); + io::open_bytes(&mut app, &locked, &two, Some(locked.clone())).unwrap(); + set(&mut app, "password", json!("pw")); + confirm(&mut app).unwrap(); + set(&mut app, "linkPages", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(placed_pages(&app), [false, false]); +} + #[test] fn single_page_pdfs_open_directly() { let mut app = app(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28944173dd..ec5682acfb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1630,6 +1630,7 @@ font-technology Variable Променливи Images: Изображения: Import Keyboard Shortcuts… Импортиране на клавишни комбинации… Import PDF Импортиране на PDF + Import PDF pages as links Импортиране на страниците на PDF като връзки Import PDF Presets Импортиране на PDF предварителни настройки Import Perspective Grid Presets Импортиране на предварителни настройки за перспективна мрежа Import Print Presets Импортиране на предварителни настройки за печат diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..fd8dbf7a2f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2598,6 +2598,7 @@ digits Malayalam Malajalam e.g. 1-3, 5 npr. 1-3, 5 First Page Prva stranica Import PDF Uvezi PDF + Import PDF pages as links Uvezi stranice PDF-a kao veze Last Page Posljednja stranica Next Page Sljedeća stranica of {count} od {count} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..9284836b8e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2101,6 +2101,7 @@ digits Malayalam Malaiàlam Import Library… Importa biblioteca… Import Matches Importa coincidències Import PDF Importa PDF + Import PDF pages as links Importa les pàgines del PDF com a enllaços Import PDF Presets Importa preajustos de PDF Import Perspective Grid Presets Importa preajustos de quadrícula de perspectiva Import Print Presets Importa preajustos d’impressió diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..cb4412c964 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2633,6 +2633,7 @@ digits Malayalam Malajálamské e.g. 1-3, 5 např. 1-3, 5 First Page První stránka Import PDF Importovat PDF + Import PDF pages as links Importovat stránky PDF jako propojení Last Page Poslední stránka Next Page Další stránka of {count} z {count} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b3de0ae89a..f4b8254aa6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2415,6 +2415,7 @@ digits Malayalam Malayalam e.g. 1-3, 5 f.eks. 1-3, 5 First Page Første side Import PDF Importér PDF + Import PDF pages as links Importér PDF-sider som links Last Page Sidste side Next Page Næste side of {count} af {count} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..0a49a92806 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1689,6 +1689,7 @@ file-filter Templates Vorlagen Images: Bilder: Import Keyboard Shortcuts… Tastaturbefehle importieren… Import PDF PDF importieren + Import PDF pages as links PDF-Seiten als Verknüpfungen importieren Import PDF Presets PDF-Vorgaben importieren Import Perspective Grid Presets Vorgaben für Perspektivenraster importieren Import Print Presets Druckvorgaben importieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..0478d0793b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1604,6 +1604,7 @@ font-technology Variable Μεταβλητές Images: Εικόνες: Import Keyboard Shortcuts… Εισαγωγή συντομεύσεων πληκτρολογίου… Import PDF Εισαγωγή PDF + Import PDF pages as links Εισαγωγή σελίδων PDF ως συνδέσμων Import PDF Presets Εισαγωγή προεπιλογών PDF Import Perspective Grid Presets Εισαγωγή προεπιλογών πλέγματος προοπτικής Import Print Presets Εισαγωγή προεπιλογών εκτύπωσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..d2eca97fc7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1685,6 +1685,7 @@ font-technology Variable Variables Images: Imágenes: Import Keyboard Shortcuts… Importar métodos abreviados de teclado… Import PDF Importar PDF + Import PDF pages as links Importar páginas PDF como vínculos Import PDF Presets Importar preajustes de PDF Import Perspective Grid Presets Importar preajustes de cuadrícula de perspectiva Import Print Presets Importar preajustes de impresión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..4a994cf1c7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2577,6 +2577,7 @@ digits Malayalam Malajalam e.g. 1-3, 5 esim. 1-3, 5 First Page Ensimmäinen sivu Import PDF Tuo PDF + Import PDF pages as links Tuo PDF-sivut linkkeinä Last Page Viimeinen sivu Next Page Seuraava sivu of {count} / {count} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..7aa913d284 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1679,6 +1679,7 @@ font-technology Variable Variables Images: Images : Import Keyboard Shortcuts… Importer des raccourcis clavier… Import PDF Importer un PDF + Import PDF pages as links Importer les pages PDF comme liens Import PDF Presets Importer des paramètres prédéfinis PDF Import Perspective Grid Presets Importer des paramètres prédéfinis de grille de perspective Import Print Presets Importer des paramètres prédéfinis d’impression diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..2921a197aa 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -1708,6 +1708,7 @@ Import Library લાઇબ્રેરી આયાત કરો Import Matches આયાત મેળ Import PDF PDF આયાત કરો + Import PDF pages as links PDF પેજને લિંક તરીકે આયાત કરો Import PDF Presets PDF આયાત પ્રીસેટ્સ Import Perspective Grid Presets પર્સ્પેક્ટિવ ગ્રિડ પ્રીસેટ્સ આયાત કરો Import Preferences પસંદગીઓ આયાત કરો diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..d3d9409f1b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2329,6 +2329,7 @@ antiAlias Sharp शार्प Import Library… आयात लाइब्रेरी… Import Matches आयात मैच Import PDF PDF आयात करें + Import PDF pages as links PDF पृष्ठों को लिंक के रूप में आयात करें Import PDF Presets PDF प्रीसेट्स इम्पोर्ट करें Import Perspective Grid Presets आयात पर्सपेक्टिव ग्रिड प्रीसेट Import Preset… प्रीसेट इम्पोर्ट करें… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a1f6100b2f..db72e54062 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2103,6 +2103,7 @@ file-filter Templates Sablonok Import Library Könyvtár importálása Import Library… Könyvtár importálása… Import PDF PDF importálása + Import PDF pages as links PDF-oldalak importálása csatolásként Import PDF Presets PDF-előbeállítások importálása Import Perspective Grid Presets Perspektivikus rács előbeállításainak importálása Import Print Presets Nyomtatási előbeállítások importálása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c7c6b956db..92c34768ba 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2415,6 +2415,7 @@ digits Malayalam Malayalam e.g. 1-3, 5 mis. 1-3, 5 First Page Halaman pertama Import PDF Impor PDF + Import PDF pages as links Impor halaman PDF sebagai tautan Last Page Halaman terakhir Next Page Halaman berikutnya of {count} dari {count} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6743704785..f5cf2db813 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1720,6 +1720,7 @@ file-filter Templates Modelli Images: Immagini: Import Keyboard Shortcuts… Importa scelte rapide da tastiera… Import PDF Importa PDF + Import PDF pages as links Importa le pagine PDF come collegamenti Import PDF Presets Importa preset PDF Import Perspective Grid Presets Importa preset griglia prospettica Import Print Presets Importa preset di stampa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9cdb8fcbe..924e25e686 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2071,6 +2071,7 @@ antiAlias Sharp シャープ Images: イメージ: Import Keyboard Shortcuts… キーボードショートカットを読み込み… Import PDF PDF を読み込み + Import PDF pages as links PDF ページをリンクとして読み込み Import PDF Presets PDF プリセットを読み込み Import Perspective Grid Presets 遠近グリッドプリセットを読み込み Import Print Presets プリントプリセットを読み込み diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..7213afe8c6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2339,6 +2339,7 @@ digits Malayalam მალაიალამური e.g. 1-3, 5 მაგ. 1-3, 5 First Page პირველი გვერდი Import PDF PDF-ის იმპორტი + Import PDF pages as links PDF-ის გვერდების იმპორტი ბმულებად Last Page ბოლო გვერდი Next Page შემდეგი გვერდი of {count} / {count} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..1ddc464da2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2061,6 +2061,7 @@ antiAlias Sharp 날카롭게 Images: 이미지: Import Keyboard Shortcuts… 단축키 가져오기… Import PDF PDF 가져오기 + Import PDF pages as links PDF 페이지를 링크로 가져오기 Import PDF Presets PDF 프리셋 가져오기 Import Perspective Grid Presets 원근 격자 프리셋 가져오기 Import Print Presets 인쇄 프리셋 가져오기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..d08895b259 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2933,6 +2933,7 @@ digits Malayalam Malayalam e.g. 1-3, 5 bijv. 1-3, 5 First Page Eerste pagina Import PDF PDF importeren + Import PDF pages as links PDF-pagina's importeren als koppelingen Last Page Laatste pagina Next Page Volgende pagina of {count} van {count} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..f681ddb7c8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2548,6 +2548,7 @@ digits Malayalam Malajalam e.g. 1-3, 5 np. 1-3, 5 First Page Pierwsza strona Import PDF Importuj PDF + Import PDF pages as links Importuj strony PDF jako łącza Last Page Ostatnia strona Next Page Następna strona of {count} z {count} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..367fd623c6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1483,6 +1483,7 @@ antiAlias Sharp Nítido Import Keyboard Shortcuts… Importar Atalhos de Teclado… Import Data Sets… Importar Conjuntos de Dados… Import PDF Importar PDF + Import PDF pages as links Importar páginas do PDF como links Import PDF Presets Importar Predefinidos de PDF Import Perspective Grid Presets Importar Predefinidos de Grade de Perspectiva Import Print Presets Importar Predefinidos de Impressão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..2f30120cf5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1709,6 +1709,7 @@ file-filter Templates Шаблоны Images: Изображения: Import Keyboard Shortcuts… Импортировать сочетания клавиш… Import PDF Импортировать PDF + Import PDF pages as links Импортировать страницы PDF как связи Import PDF Presets Импортировать наборы PDF Import Perspective Grid Presets Импортировать наборы сетки перспективы Import Print Presets Импортировать наборы печати diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..51867c4a71 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1643,6 +1643,7 @@ font-technology Variable Variabla Images: Bilder: Import Keyboard Shortcuts… Importera kortkommandon… Import PDF Importera PDF + Import PDF pages as links Importera PDF-sidor som länkar Import PDF Presets Importera PDF-förinställningar Import Perspective Grid Presets Importera förinställningar för perspektivrutnät Import Print Presets Importera utskriftsförinställningar diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..b9666088ce 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1521,6 +1521,7 @@ font-technology Variable Değişken Images: Görseller: Import Keyboard Shortcuts… Klavye Kısayollarını İçe Aktar… Import PDF PDF'yi içe aktar + Import PDF pages as links PDF sayfalarını bağlantı olarak içe aktar Import PDF Presets PDF Hazır Ayarlarını İçe Aktar Import Perspective Grid Presets Perspektif Izgarası Ön Ayarlarını İçe Aktar Import Print Presets Yazdırma Ön Ayarlarını İçe Aktar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..598693337f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1714,6 +1714,7 @@ file-filter Templates Шаблони Images: Зображення: Import Keyboard Shortcuts… Імпортувати сполучення клавіш… Import PDF Імпортувати PDF + Import PDF pages as links Імпортувати сторінки PDF як зв’язки Import PDF Presets Імпортувати набори PDF Import Perspective Grid Presets Імпортувати набори сітки перспективи Import Print Presets Імпортувати набори друку diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..54410fe1ef 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1955,6 +1955,7 @@ digits Hindi Hindi Import Library Nhập thư viện Import Library… Nhập thư viện… Import PDF Nhập PDF + Import PDF pages as links Nhập các trang PDF dưới dạng liên kết Import PDF Presets Nhập thiết lập sẵn PDF Import Perspective Grid Presets Nhập thiết lập sẵn lưới phối cảnh Import Print Presets Nhập thiết lập sẵn in diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..0c21a6bcf7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1639,6 +1639,7 @@ file-filter Templates 模板 Images: 图像: Import Keyboard Shortcuts… 导入键盘快捷键… Import PDF 导入 PDF + Import PDF pages as links 将 PDF 页面导入为链接 Import PDF Presets 导入 PDF 预设 Import Perspective Grid Presets 导入透视网格预设 Import Print Presets 导入打印预设 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..b2c7dbcc32 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1686,6 +1686,7 @@ file-filter Templates 範本 Images: 影像: Import Keyboard Shortcuts… 匯入鍵盤快速鍵… Import PDF 匯入 PDF + Import PDF pages as links 將 PDF 頁面匯入為連結 Import PDF Presets 匯入 PDF 預設集 Import Perspective Grid Presets 匯入透視格點預設集 Import Print Presets 匯入列印預設集 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a5ff38be13..4fd58266e9 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, | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) | | 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. | +| 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; Import PDF pages as links opens each page as a placed document linked to it, on its own artboard, `tests/placed_document.rs`) | 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) | | 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` | diff --git a/docs/mcp.md b/docs/mcp.md index 2e43c14d0e..4f7585493b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1248,6 +1248,15 @@ empty `boxes`, and `thumbnail: 1` shows what that page prints. A page whose `Use opens at its size in points, the file's numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size. +`linkPages: true` (File › Open's Import PDF dialog: Import PDF pages as links) opens each page `pages` picks as a +placed document linked to that page of the file, as `file.place` with `link` places one, instead of as editable art. +Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds), in a row from the left 36 pt apart, and +a layer named after the page (`Page 2`). The document opens in CMYK when most of those pages are painted mostly in +CMYK, unless `colorMode` names a mode. All the pages' objects share one copy of the file. The Links panel lists each +page, and `links.embed` makes a page editable art. +`document.open` refuses `linkPages` for a file that is not a PDF or a PDF-compatible `.ai`, for a file given as +`dataBase64` without a path, with a `password`, and with `textAs: "outlines"`, and rejects a `linkPages` that is not +true or false. Save then asks for a name. `document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` that is not a string, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number. The error names the param, and nothing opens or is placed. Leaving a param out or giving null keeps its default. @@ -1263,6 +1272,7 @@ CMYK too. ```json {"name":"run_command","arguments":{"command":"document.pdfInfo","params":{"path":"/tmp/brochure.pdf","thumbnail":2,"cropTo":"trim"}}} {"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","pages":"2-3","cropTo":"trim"}}} +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","linkPages":true}}} ``` PostScript files (`.eps`, `.epsf`, `.ps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS From b969a82e432f8d9a6a6ac8cffb84cca50f673a08 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:22:48 -0800 Subject: [PATCH 62/82] Type: Smart Punctuation keeps ruby on its characters and skips fractions next to any digit Each run is rewritten in pieces between the ruby spans' ends, so no replacement joins characters across one, and each span is moved to where its characters land in the new text. Expert Fractions treats a digit of any script next to a fraction as part of a larger number. --- crates/engine/src/cmd/typemenu.rs | 55 +++++++++++++++++++++++++---- crates/engine/src/tests/menucmds.rs | 36 +++++++++++++++++++ docs/mcp.md | 9 ++--- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index e7b25778ef..d793fc12a3 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -367,7 +367,8 @@ fn smart_spaces(text: &str, after: &mut Option, n: &mut usize) -> String } /// The fraction `cs` starts with, and its length in characters: one of [`FRACTIONS`] that no digit, -/// slash or decimal part follows, so `1/25`, `1/2/2026` and `1/2.5` are left as they are. +/// slash or decimal part follows, so `1/25`, `1/2/2026` and `1/2.5` are left as they are. A digit +/// counts in any script. fn fraction_at(cs: &[char]) -> Option<(usize, char)> { FRACTIONS.iter().find_map(|(text, f)| { let len = text.chars().count(); @@ -375,8 +376,8 @@ fn fraction_at(cs: &[char]) -> Option<(usize, char)> { return None; } let ends = match &cs[len..] { - [c, ..] if c.is_ascii_digit() || *c == '/' => false, - ['.' | ',', d, ..] if d.is_ascii_digit() => false, + [c, ..] if c.is_numeric() || *c == '/' => false, + ['.' | ',', d, ..] if d.is_numeric() => false, _ => true, }; ends.then_some((len, *f)) @@ -395,7 +396,7 @@ fn expert_fractions(text: &str, prev: Option, next: [Option; 2], cov let mut i = 0; while i < len_text { let before = if i == 0 { prev } else { Some(cs[i - 1]) }; - let free = !before.is_some_and(|c| c.is_ascii_digit() || matches!(c, '/' | '.' | ',')); + let free = !before.is_some_and(|c| c.is_numeric() || matches!(c, '/' | '.' | ',')); let found = fraction_at(&cs[i..]).filter(|(len, f)| i + len <= len_text && covers(*f)); if free && let Some((len, f)) = found { out.push(f); @@ -527,27 +528,69 @@ fn smart_punctuation(s: &mut Session, p: &Value) -> Result { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let mut st = PunctState::default(); let mut any = false; + // The ruby spans' ends, in the plain text as it was. Each run is rewritten in pieces + // between them, so no replacement crosses one, and `moved` records where each end and + // each run's start lands in the new text. + let mut edges: Vec = t.ruby.iter().flat_map(|r| [r.start, r.end]).collect(); + edges.sort_unstable(); + edges.dedup(); + let mut moved = vec![]; + let (mut old_at, mut new_at) = (0, 0); // Inline graphics are art, not characters to rewrite. Each run sees the first two // characters of the runs after it, up to the next inline graphic, as they were. for i in 0..t.runs.len() { + let len = t.runs[i].text.len(); + moved.push((old_at, new_at)); if t.runs[i].inline.is_some() { // A graphic separates the characters on either side of it for Smart Spaces and // Expert Fractions; quotes still read the character before it. st.after_period = None; st.after_graphic = true; + (old_at, new_at) = (old_at + len, new_at + len); continue; } let mut after = t.runs[i + 1..].iter().take_while(|r| r.inline.is_none()).flat_map(|r| r.text.chars()); - let next = [after.next(), after.next()]; + let following = [after.next(), after.next()]; + // Smart Spaces counts no space across a ruby span's end, so it removes no space on + // the other side of one. + if edges.binary_search(&old_at).is_ok() { + st.after_period = None; + } let r = &mut t.runs[i]; - let new = smarten(&r.text, &r.style, next, &o, &mut st, &mut n); + let mut cuts: Vec = + edges.iter().filter(|&&e| e > old_at && e < old_at + len && r.text.is_char_boundary(e - old_at)).map(|e| e - old_at).collect(); + cuts.push(len); + let mut new = String::with_capacity(len); + let mut from = 0; + for cut in cuts { + if from > 0 { + moved.push((old_at + from, new_at + new.len())); + st.after_period = None; + } + let mut after = r.text[cut..].chars().chain(following.into_iter().flatten()); + let next = [after.next(), after.next()]; + new.push_str(&smarten(&r.text[from..cut], &r.style, next, &o, &mut st, &mut n)); + from = cut; + } + (old_at, new_at) = (old_at + len, new_at + new.len()); if new != r.text { r.text = new; any = true; } } + moved.push((old_at, new_at)); if any { changed += 1; + // Each ruby end moves with the text before it; an end inside a character keeps its + // distance from the last place recorded before it. + let to_new = |v: usize| { + let k = moved.partition_point(|&(old, _)| old <= v).saturating_sub(1); + moved.get(k).map_or(v, |&(old, new)| new + (v - old.min(v))) + }; + for span in &mut t.ruby { + (span.start, span.end) = (to_new(span.start), to_new(span.end)); + } + t.normalize_ruby(); refresh_bounds(t); } } diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 90ac750157..9d0f86fb3f 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3671,6 +3671,42 @@ fn smart_spaces_keeps_one_space_after_a_period() { assert_eq!(r["replaced"]["spaces"], 2); } +/// Ruby stays on the characters it was set on when Smart Punctuation makes the text before them +/// longer (curly quotes) or shorter (Smart Spaces), and no replacement takes in characters on both +/// sides of a ruby span's end: the fi with ruby on its i stays as letters, and a second space after +/// a period with ruby of its own stays. +#[test] +fn smart_punctuation_keeps_ruby_on_its_characters() { + let mut s = session(); + let spans = |s: &Session, t| match node(s, t).kind { + NodeKind::Text(o) => o.ruby.iter().map(|r| (r.start, r.end)).collect::>(), + _ => vec![], + }; + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "'A'. base fit X. Y", "font": "Source Sans 3"})).unwrap()); + sel(&mut s, &[t]); + for (start, end) in [(1, 2), (6, 10), (12, 13), (18, 19)] { + s.execute("type.ruby", &json!({"text": "r", "start": start, "end": end})).unwrap(); + } + let r = s.execute("type.smartPunctuation", &json!({"spaces": true, "fiLigatures": true})).unwrap(); + assert_eq!(plain(&s, t), "‘A’. base fit X. Y"); + assert_eq!((&r["replaced"]["spaces"], &r["replaced"]["fiLigatures"]), (&json!(1), &json!(0))); + let text = plain(&s, t); + let on: Vec<&str> = spans(&s, t).into_iter().map(|(a, b)| &text[a..b]).collect(); + assert_eq!(on, ["A", "base", "i", " "]); +} + +/// Expert Fractions leaves a fraction next to a digit of another script as it is. +#[test] +fn expert_fractions_skip_fractions_next_to_digits_of_any_script() { + let mut s = session(); + let words = "\u{662}1/2 1/2\u{662} 1/2.\u{662} 1/2"; + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": words, "font": "Source Sans 3"})).unwrap()); + sel(&mut s, &[t]); + let r = s.execute("type.smartPunctuation", &json!({"fractions": true})).unwrap(); + assert_eq!(plain(&s, t), "\u{662}1/2 1/2\u{662} 1/2.\u{662} ½"); + assert_eq!(r["replaced"]["fractions"], 1); +} + #[test] fn convert_point_area_roundtrip() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 16d419ad72..294a2b9c24 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8624,11 +8624,12 @@ on again. - `dashes` (default `true`): `--` becomes an en dash and `---` an em dash. - `ellipsis` (default `true`): `...` becomes an ellipsis. - `fractions` (default `false`): 1/2, 1/4, 3/4 and the other fractions Unicode encodes as one character (thirds, - fifths, sixths, sevenths, eighths, ninths and 1/10) become that character. A fraction with a digit, a slash or a - decimal point next to it, as in 1/25, 1/2/26 or 1/2.5, is left as it is, also when a change of style falls inside - it. An inline graphic separates the text before it from the text after it, for this option and for `spaces`. + fifths, sixths, sevenths, eighths, ninths and 1/10) become that character. A fraction with a digit of any script, a + slash or a decimal point next to it, as in 1/25, 1/2/26 or 1/2.5, is left as it is, also when a change of style falls + inside it. An inline graphic separates the text before it from the text after it, for this option and for `spaces`. -A ligature or a fraction is written only where the run's font has a glyph for it; elsewhere the letters stay. A value +A ligature or a fraction is written only where the run's font has a glyph for it; elsewhere the letters stay. Ruby +stays on the characters it was set on, and no replacement joins characters on both sides of a ruby span's end. A value other than true, false or null is refused. The reply gives the number of text objects changed and how many of each replacement were made; with nothing to replace, no undo step is recorded. Type › Smart Punctuation… opens a dialog with these options, Replace In (Selected Text Only or Entire Document) and Report Results, which lists the count of each From af47bc5c3bb640aa434963bbb1a381f4dc27d551 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:33:08 -0800 Subject: [PATCH 63/82] PDF export: a malformed gray or RGB profile is left out of an image A gray or RGB image is tagged with the ICC profile it carries only when the profile passes the checks a CMYK image's profile already passes: at most 16 MiB, the size its header declares, the acsp signature, an input, display, output or color space profile class, and a tag table that lies within it. Otherwise the image is written in DeviceGray or DeviceRGB, kept as it is, resampled or compressed with JPEG. --- crates/pdf/src/images.rs | 28 ++++++++++++++-------------- crates/pdf/src/tests/compression.rs | 23 +++++++++++++++++++++-- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 0085f07538..9f31b1742d 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -68,15 +68,15 @@ impl CustomImage for Raw { } } -/// `icc` when it is a profile of the colour space `space` names: `GRAY` for grey samples, `RGB ` -/// for RGB ones. `None` for other profiles and spaces. +/// `icc` when it is a well-formed profile ([`valid_icc`]) of the colour space `space` names: `GRAY` +/// for grey samples, `RGB ` for RGB ones. `None` for other profiles and spaces. fn icc_for(icc: Option>, space: ImageColorspace) -> Option>> { let want = match space { ImageColorspace::Luma => *b"GRAY", ImageColorspace::Rgb => *b"RGB ", ImageColorspace::Cmyk | ImageColorspace::DeviceN => return None, }; - icc.filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)).map(Arc::new) + icc.filter(|icc| valid_icc(icc, want)).map(Arc::new) } /// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, @@ -100,7 +100,7 @@ fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Op if px.len() as u64 != width as u64 * height as u64 || px.is_empty() { return None; } - let rgb_icc = pixels.is_none() && icc.as_deref().and_then(vectorcraft_doc::cmyk::icc_color_space) == Some(*b"RGB "); + let rgb_icc = pixels.is_none() && icc.as_deref().is_some_and(|icc| valid_icc(icc, *b"RGB ")); let (color, space) = if neutral(rgba) && !rgb_icc { (px.iter().map(|p| p[0]).collect(), ImageColorspace::Luma) } else if let Some(cmyk) = pixels.and_then(|p| p.cmyk(rgba)) { @@ -175,19 +175,20 @@ fn lossless( /// it holds is still written. pub(crate) const MAX_EMBED_PIXELS: u64 = 1 << 27; -/// The most bytes a CMYK profile carried by an image is written with (16 MiB). +/// The most bytes an ICC profile carried by an image is written with (16 MiB). const MAX_ICC: usize = 16 << 20; /// The CMYK ICC profile image `blob` carries ([`ImageBlob::cmyk_icc_profile`]), when it is one a -/// CMYK image can be tagged with: at most [`MAX_ICC`] bytes, the size its header declares, the -/// `acsp` signature, an input, display, output or colour space profile of CMYK colours, and a tag -/// table whose tags lie within it. `None` for other images and other profiles. +/// CMYK image can be tagged with ([`valid_icc`]). `None` for other images and other profiles. pub(crate) fn cmyk_icc(blob: &ImageBlob) -> Option> { - blob.cmyk_icc_profile().filter(|icc| valid_cmyk_icc(icc)) + blob.cmyk_icc_profile().filter(|icc| valid_icc(icc, *b"CMYK")) } -/// Is `icc` a CMYK profile as [`cmyk_icc`] describes? -fn valid_cmyk_icc(icc: &[u8]) -> bool { +/// Is `icc` a profile an image of colour space `space` (`GRAY`, `RGB ` or `CMYK`) can be tagged +/// with: at most [`MAX_ICC`] bytes, the size its header declares, the `acsp` signature, an input, +/// display, output or colour space profile of `space` colours, and a tag table whose tags lie +/// within it? +fn valid_icc(icc: &[u8], space: [u8; 4]) -> bool { let be = |at: usize| icc.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); let fits = |at: u32, len: u32| u64::from(at) + u64::from(len) <= icc.len() as u64; if icc.len() > MAX_ICC || be(0).and_then(|n| usize::try_from(n).ok()) != Some(icc.len()) { @@ -195,7 +196,7 @@ fn valid_cmyk_icc(icc: &[u8]) -> bool { } let class = icc.get(12..16); if icc.get(36..40) != Some(b"acsp".as_slice()) - || icc.get(16..20) != Some(b"CMYK".as_slice()) + || icc.get(16..20) != Some(space.as_slice()) || ![b"scnr", b"mntr", b"prtr", b"spac"].iter().any(|c| class == Some(c.as_slice())) { return false; @@ -412,8 +413,7 @@ pub(crate) fn recode( // Pixels that keep their colours keep the profile they are in; converted ones are in the // destination's. let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" }; - let icc = - (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)); + let icc = (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want)); let rgb_icc = icc.is_some() && kind == Kind::Color; let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8(); if kind == Kind::Mono { diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index e8c750f024..10bcb54dc1 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -270,8 +270,8 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { assert!(r.image.is_none(), "kept as it is"); } -/// A version 2.1 display ICC profile header for colour space `space` (`RGB ` or `GRAY`), `len` -/// bytes in all, every byte after the header different from its neighbors. +/// A version 2.1 display ICC profile for colour space `space` (`RGB ` or `GRAY`), `len` bytes in +/// all, with an empty tag table, every byte after the tag count different from its neighbors. fn icc_profile(space: &[u8; 4], len: usize) -> Vec { let mut icc: Vec = (0..len).map(|i| (i % 251) as u8).collect(); icc[..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes()); @@ -279,6 +279,7 @@ fn icc_profile(space: &[u8; 4], len: usize) -> Vec { icc[12..16].copy_from_slice(b"mntr"); icc[16..20].copy_from_slice(space); icc[36..40].copy_from_slice(b"acsp"); + icc[128..132].copy_from_slice(&0u32.to_be_bytes()); icc } @@ -450,6 +451,24 @@ fn tagged_tiffs_keep_their_icc_profile() { // A profile of another colour space is left out. let spaces = export(&tiff(&colour(), &gray), json!({})); assert!(spaces.len() == 1 && spaces[0].space == "DeviceRGB" && spaces[0].icc.is_none(), "RGB in a gray profile: {spaces:?}"); + // A malformed profile is left out, the image kept as it is, resampled or compressed with JPEG: + // one whose header declares another size, one without the `acsp` signature, and one whose tag + // table runs past its end. + let mut malformed = [rgb.clone(), rgb.clone(), rgb.clone()]; + malformed[0][..4].copy_from_slice(&601u32.to_be_bytes()); + malformed[1][36..40].copy_from_slice(b"xxxx"); + malformed[2][128..132].copy_from_slice(&100u32.to_be_bytes()); + let compressions = + [json!({}), json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"color": {"compression": "jpeg"}})]; + for (i, icc) in malformed.iter().enumerate() { + for compression in &compressions { + let spaces = export(&tiff(&colour(), icc), json!({ "compression": compression })); + assert!( + spaces.len() == 1 && spaces[0].space == "DeviceRGB" && spaces[0].icc.is_none(), + "malformed profile {i}, {compression}: {spaces:?}" + ); + } + } // Converted pixels are in the destination's colours, not the image's profile. for destination in [vectorcraft_color::cms::DISPLAY_P3, vectorcraft_color::cms::GENERIC_CMYK] { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); From 15867198980a09e6f294901515104e3858b27b0c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:47:23 -0800 Subject: [PATCH 64/82] Tests: Insert Aki fixture sets kinsoku_sets main added Texts::kinsoku_sets; the a_run_keeps_its_insert_aki fixture now sets it to an empty list, as the other Texts fixtures do. --- crates/eps/src/import/ate.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 8e9bbe96c1..a45c203d75 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -1958,6 +1958,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let aki = |defaults: &[u8], normal: &[u8], run: &[u8]| { let s = texts(defaults, normal).style(Some(&read(run).unwrap())); From 2b781525bb926d7c499346debc72dd76f1f55684 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:50:18 -0800 Subject: [PATCH 65/82] Type: a tab to a default or left stop reads none of the text after it tab_advance read the text after a tab up to the next tab for every stop, though only right, center and decimal stops align it. The first tab of a long paragraph read the paragraph to its end, even on a line that stops reading a bounded number of breaks past its overflow. It now reads that text only when it reaches a stop that aligns it. --- crates/text/src/layout/breaking.rs | 36 ++++++++++++++++-------- crates/text/src/layout/wrapping_tests.rs | 18 ++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 4b89f79ae6..cfe5fa0eb6 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -214,18 +214,30 @@ pub(super) fn tab_advance<'t, 'g>( ) -> (f64, &'t str) { use vectorcraft_doc::TabAlign; let rel = x - origin; - let seg: Vec<&SGlyph> = rest.into_iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect(); - let width: f64 = seg.iter().map(|g| g.adv).sum(); - let target = |pos: f64, align: TabAlign, on: char| match align { - TabAlign::Left => pos, - TabAlign::Right => pos - width, - TabAlign::Center => pos - width / 2.0, - TabAlign::Decimal => pos - seg.iter().take_while(|g| g.ch != on).map(|g| g.adv).sum::(), - }; - let explicit = tabs.iter().find_map(|t| { - let at = target(t.position, t.align, t.align_on); - (at > rel + 1e-6).then_some((at, t.leader.as_str())) - }); + // The text after the tab is read only when a right, center or decimal stop is reached, since + // only those align it. A tab to a default or left stop reads none of it. + let mut rest = Some(rest); + let mut seg: Vec<&SGlyph> = Vec::new(); + let mut width = 0.0; + let mut explicit = None; + for t in tabs { + if t.align != TabAlign::Left + && let Some(r) = rest.take() + { + seg = r.into_iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect(); + width = seg.iter().map(|g| g.adv).sum(); + } + let at = match t.align { + TabAlign::Left => t.position, + TabAlign::Right => t.position - width, + TabAlign::Center => t.position - width / 2.0, + TabAlign::Decimal => t.position - seg.iter().take_while(|g| g.ch != t.align_on).map(|g| g.adv).sum::(), + }; + if at > rel + 1e-6 { + explicit = Some((at, t.leader.as_str())); + break; + } + } let (at, leader) = explicit.unwrap_or_else(|| { let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0); let base = rel.max(last); diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index be089b4434..89d40386b5 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -745,6 +745,24 @@ fn a_tabbed_line_stepping_back_ends_at_a_final_soft_hyphen_without_a_hyphen() { assert_eq!(ended, (len, false)); } +/// A tab to a default or left stop does not read the text after it, which only right, center and +/// decimal stops align: the first tab of a long paragraph does not read the paragraph to its end. +#[test] +fn a_tab_to_a_default_or_left_stop_reads_no_text_after_it() { + use vectorcraft_doc::{TabAlign, TabStop}; + let (_, glyphs) = stepping_back_tabbed_line(2000); + let read = std::cell::Cell::new(0usize); + let rest = || glyphs[2..].iter().inspect(|_| read.set(read.get() + 1)); + let left = [TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + assert_eq!(tab_advance(&[], 0.0, 1.0, rest()).0, 35.0); + assert_eq!(tab_advance(&left, 0.0, 1.0, rest()).0, 99.0); + assert_eq!(read.get(), 0, "read {} glyphs after the tab", read.get()); + // A right stop reads up to the next tab and aligns that text on the stop. + let right = [TabStop { position: 100.0, align: TabAlign::Right, leader: String::new(), align_on: '.' }]; + let short = synthetic_glyphs(&[(0, 1, 2.0, 'b'), (1, 1, 3.0, 'b'), (2, 1, 0.0, '\t'), (3, 1, 7.0, 'b')]); + assert_eq!(tab_advance(&right, 0.0, 1.0, short.iter()).0, 94.0); +} + /// 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] From c96169ac1d86f760e2bd97785399ee57a673b1e7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:55:55 -0800 Subject: [PATCH 66/82] OpenType panel: drop the first-run features binding the note no longer reads After the merge of main, the tracked-ligatures note is the only reader of the first run's features, and the branch replaces that check with one that reads every frame of the story, so clippy reported the binding as unused. --- crates/ui-egui/src/panels/opentype.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index cbb2ec50f8..2d872872ff 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -203,7 +203,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::dim_label(ui, tl!("Select text to set its OpenType features.")); return; }; - let cur = OtFeatures::from_tags(st.features.iter().map(String::as_str)); let mut change = None; let fig = shown_figure(app); ui.horizontal(|ui| { From 8e6c948010325317be652c47dee89c2993ff98ca Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:56:47 -0800 Subject: [PATCH 67/82] PDF export: a JPEG written without its profile keeps its JPEG data In PDF/X-1a, a tagged gray JPEG that the Compression settings keep as JPEG was decoded and written with Flate. It is now written with its JPEG data unchanged, in DeviceGray without its profile. A PNG or JPEG embedded as it is now has its gray or RGB profile checked with valid_icc, like the images the recoder writes: a malformed profile is left out, the JPEG keeping its JPEG data and the PNG decoded. --- crates/pdf/src/export/images.rs | 25 +++++++------ crates/pdf/src/images.rs | 2 +- crates/pdf/src/tests/compression.rs | 58 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index c76ac6de98..d2225d6766 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -319,9 +319,10 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), /// other formats (a TIFF) decoded with their 16-bit samples and, when `profiles` allows it (not in /// PDF/X-1a), the ICC profile they carry ([`crate::images::decoded_image`]). Without `profiles`, an -/// image of any format that carries a gray or RGB profile is decoded too, and written without it. -/// Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of 16 bits a sample is -/// written at 8 bits ([`png_8_bit`]). +/// image that carries a gray or RGB profile is written without it, and so is one whose gray or RGB +/// profile is malformed ([`crate::images::valid_icc`]): a JPEG with its data unchanged, an image of +/// another format decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of +/// 16 bits a sample is written at 8 bits ([`png_8_bit`]). pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. @@ -330,14 +331,16 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee return None; } let bytes = blob.bytes.as_ref().clone(); - // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, such an image is - // decoded and written without it. - let tagged = || { - let space = crate::images::source_icc(&bytes).and_then(|icc| vectorcraft_doc::cmyk::icc_color_space(&icc)); - matches!(space.as_ref(), Some(b"GRAY" | b"RGB ")) - }; - let mime = if !profiles && tagged() { "" } else { blob.mime.as_str() }; - let direct = match mime { + // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, or when the + // profile is malformed, such a JPEG is written without its profile, and an image of another + // format is decoded and written without it. + let strip = crate::images::source_icc(&bytes).is_some_and(|icc| match vectorcraft_doc::cmyk::icc_color_space(&icc).as_ref() { + Some(space @ (b"GRAY" | b"RGB ")) => !profiles || !crate::images::valid_icc(&icc, *space), + _ => false, + }); + let direct = match blob.mime.as_str() { + "image/jpeg" | "image/jpg" if strip => Image::from_jpeg_with_icc(bytes.clone().into(), None, interpolate).ok(), + _ if strip => None, "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 9f31b1742d..5204e4f396 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -188,7 +188,7 @@ pub(crate) fn cmyk_icc(blob: &ImageBlob) -> Option> { /// with: at most [`MAX_ICC`] bytes, the size its header declares, the `acsp` signature, an input, /// display, output or colour space profile of `space` colours, and a tag table whose tags lie /// within it? -fn valid_icc(icc: &[u8], space: [u8; 4]) -> bool { +pub(crate) fn valid_icc(icc: &[u8], space: [u8; 4]) -> bool { let be = |at: usize| icc.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); let fits = |at: u32, len: u32| u64::from(at) + u64::from(len) <= icc.len() as u64; if icc.len() > MAX_ICC || be(0).and_then(|n| usize::try_from(n).ok()) != Some(icc.len()) { diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 10bcb54dc1..1f8e3e80d6 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -365,6 +365,64 @@ fn image_samples(pdf: &[u8]) -> Vec> { images.iter().filter(|s| !masks.contains(&s.raw_data().to_vec())).map(|s| s.decoded().unwrap().into_owned()).collect() } +/// The raw (still encoded) data of each image XObject of `pdf`. +fn image_data(pdf: &[u8]) -> Vec> { + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); + file.objects() + .into_iter() + .filter_map(|o| match o { + Object::Stream(s) if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => Some(s.raw_data().to_vec()), + _ => None, + }) + .collect() +} + +/// A PNG or JPEG embedded as it is whose gray or RGB profile is malformed is written without it, +/// the JPEG with its JPEG data unchanged: a profile whose header declares another size, one without +/// the `acsp` signature, and one whose tag table runs past its end. +#[test] +fn a_malformed_profile_is_left_out_of_a_png_or_jpeg_kept_as_it_is() { + for (space, img, device) in [(b"RGB ", colour(), "DeviceRGB"), (b"GRAY", colour().grayscale(), "DeviceGray")] { + let mut malformed = [icc_profile(space, 600), icc_profile(space, 600), icc_profile(space, 600)]; + malformed[0][..4].copy_from_slice(&601u32.to_be_bytes()); + malformed[1][36..40].copy_from_slice(b"xxxx"); + malformed[2][128..132].copy_from_slice(&100u32.to_be_bytes()); + for (i, icc) in malformed.iter().enumerate() { + for (format, mime, filter) in [(ImageFormat::Png, "image/png", "FlateDecode"), (ImageFormat::Jpeg, "image/jpeg", "DCTDecode")] { + let what = format!("{device}, {format:?}, malformed profile {i}"); + let bytes = tagged(&img, icc, format); + let mut settings = PdfSettings::default(); + settings.compression.compress_text = false; + let pdf = export_with_report(&doc(bytes.clone(), mime), &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let spaces = image_spaces(&pdf); + let plain = spaces.len() == 1 && spaces[0].space == device && spaces[0].icc.is_none() && spaces[0].filter == filter; + assert!(plain, "{what}: {spaces:?}"); + if format == ImageFormat::Jpeg { + assert!(image_data(&pdf) == [bytes], "{what}: the JPEG data unchanged"); + } + } + } + } +} + +/// In PDF/X-1a a tagged gray JPEG kept as JPEG is written with its JPEG data, without its profile. +#[test] +fn pdf_x1a_keeps_a_tagged_gray_jpeg_as_jpeg() { + let jpeg = tagged(&colour().grayscale(), &icc_profile(b"GRAY", 300), ImageFormat::Jpeg); + let d = doc(jpeg.clone(), "image/jpeg"); + for compression in [json!({}), json!({"gray": {"compression": "jpeg"}})] { + let mut settings: PdfSettings = + serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); + settings.compression.compress_text = false; + let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + crate::pdfx::verify(&bytes, Standard::PdfX1a).unwrap_or_else(|e| panic!("{compression}: {e}")); + let spaces = image_spaces(&bytes); + let dct = spaces.len() == 1 && spaces[0].space == "DeviceGray" && spaces[0].icc.is_none() && spaces[0].filter == "DCTDecode"; + assert!(dct, "{compression}: {spaces:?}"); + assert!(image_data(&bytes) == [jpeg.clone()], "{compression}: the JPEG data unchanged"); + } +} + /// A gray or RGB TIFF that carries an ICC profile of its colour space is written in an ICC-based /// colour space holding the same profile, at 16 bits a sample from PDF 1.5 on, with its alpha as /// a soft mask; and so is a tagged TIFF or PNG that is resampled or compressed with JPEG. Pixels diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 129079e0c0..bbd79834a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1831,7 +1831,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - PDF export writes a CMYK image that keeps its numbers in an `ICCBased` color space holding, byte for byte, the CMYK profile the image carries (a CMYK TIFF's tag 34675, or a CMYK JPEG's `ICC_PROFILE` APP2 segments) when `output.profiles` is `all` or `taggedSource`, and in PDF/A-2b, PDF/X-3 and PDF/X-4 unless it is `destination` ([the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html)). This covers a CMYK JPEG embedded as it is, one the Compression settings resample or compress again, a TIFF written again, and an image written with its mask as its soft mask. With `none`, and in PDF/X-1a, the image is DeviceCMYK and a JPEG's own profile is left out, so PDF/X-1a export writes a CMYK JPEG that carries a profile. Pixels the Output settings convert are never tagged with the profile of their old numbers. A profile tags an image only when its size, signature, class, CMYK color space and tag table are well formed and it is at most 16 MiB (VectorCraft's own limit). Transparency in a tagged CMYK document blends in the document's CMYK profile; when the file holds no space of that profile it blends in DeviceCMYK, or keeps its RGB space in PDF/A, and never in an image's own profile (`crates/pdf/src/tests/cmykimages.rs`, `export::writer::tests`). - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, and a profile whose color space differs from the samples', are written without it. PDF/X-1a allows no ICC-based color spaces, so there a gray image with a gray profile, kept as it is, resampled or compressed with JPEG, is written in DeviceGray without the profile, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`). + - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, a profile whose color space differs from the samples', and a malformed profile, a PNG's or JPEG's kept as it is included, are written without it; such a JPEG keeps its JPEG data. PDF/X-1a allows no ICC-based color spaces, so there a gray image with a gray profile, kept as it is, resampled or compressed with JPEG, is written in DeviceGray without the profile, a JPEG kept as JPEG with its JPEG data unchanged, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). - SVG import reads the files that a linked SVG file's own `` elements link to, relative paths from that file's folder (), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). From 54a7b51ec945e57be6a231e2963e14620321a1f3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:02:03 -0800 Subject: [PATCH 68/82] Type: a tabbed right-to-left line whose last break read precedes a forced line break ends after it When the last of the bounded breaks such a line reads came right before U+2028, the line ended at that break and the next line held only the forced break, drawing an empty line. The line now reads on to the forced break and ends there when it fits as drawn. --- crates/text/src/layout/breaking.rs | 8 +++++--- crates/text/src/layout/wrapping_tests.rs | 26 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index cfe5fa0eb6..989ef9ea95 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -379,8 +379,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// [`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. Once such a /// line holds a glyph with a negative advance and has been wider than `width` in the order of its -/// text, it reads at most [`MAX_DRAWN_CHECKS_STEPPING_BACK`] legal breaks further and ends at the -/// latest of the breaks read that fits as drawn. +/// text, it reads at most [`MAX_DRAWN_CHECKS_STEPPING_BACK`] legal breaks further, and a forced +/// line break right after the last of them, and ends at the latest of the breaks read that fits as +/// drawn. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -538,8 +539,9 @@ pub(super) fn break_line( // breaks past where it first overflowed in the order of its text, and ends at the // latest break read that fits as drawn: each of those breaks is measured one by one. // A soft hyphen ending the text draws no hyphen there, as at the end of the text below. + // A break right before a forced line break reads on to it: the line ends there. breaks_past_overflow += usize::from(overflowed && back > 0.0); - if breaks_past_overflow >= MAX_DRAWN_CHECKS_STEPPING_BACK { + if breaks_past_overflow >= MAX_DRAWN_CHECKS_STEPPING_BACK && g.get(cluster_end).is_none_or(|n| n.ch != '\u{2028}') { return settle(tabbed, checks(back), (cluster_end, hyphenated && cluster_end < g.len()), &breaks); } } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 89d40386b5..7dd05a9357 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -745,6 +745,32 @@ fn a_tabbed_line_stepping_back_ends_at_a_final_soft_hyphen_without_a_hyphen() { assert_eq!(ended, (len, false)); } +/// A tabbed line with glyphs stepping back whose last break read comes right before a forced line +/// break ends after the forced break, so the next line does not start with it and draw empty. +#[test] +fn a_tabbed_line_stepping_back_keeps_a_forced_break_after_its_last_break_read() { + // The 1,024th space is the 1,024th break past the overflow, and glyph 3075 after it is U+2028. + let text = format!("a\ty{}\u{2028}bbb", "bz ".repeat(1024)); + let specs: Vec<_> = text + .char_indices() + .map(|(byte, ch)| { + let adv = match ch { + 'y' | 'z' => -1.0, + ' ' | '\u{2028}' => 0.0, + _ => 1.0, + }; + (byte, ch.len_utf8(), adv, ch) + }) + .collect(); + let glyphs = synthetic_glyphs(&specs); + assert_eq!(glyphs[3075].ch, '\u{2028}'); + let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end >= 3075; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (3076, false)); +} + /// A tab to a default or left stop does not read the text after it, which only right, center and /// decimal stops align: the first tab of a long paragraph does not read the paragraph to its end. #[test] From 5afe6b3b730d1f444105053ec51f8829bd22053f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:12:25 -0800 Subject: [PATCH 69/82] PDF export: a PNG or JPEG profile cut short is left out too The check before a PNG or JPEG is embedded as it is read the profile's color space only from a profile of at least 128 bytes, so a gray or RGB profile cut short after its color space reached the PDF writer. The color space is now read from bytes 16..20 of any profile that holds them, and valid_icc rejects the short profile. --- crates/pdf/src/export/images.rs | 7 ++++--- crates/pdf/src/tests/compression.rs | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index d2225d6766..af686e1857 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -333,9 +333,10 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee let bytes = blob.bytes.as_ref().clone(); // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, or when the // profile is malformed, such a JPEG is written without its profile, and an image of another - // format is decoded and written without it. - let strip = crate::images::source_icc(&bytes).is_some_and(|icc| match vectorcraft_doc::cmyk::icc_color_space(&icc).as_ref() { - Some(space @ (b"GRAY" | b"RGB ")) => !profiles || !crate::images::valid_icc(&icc, *space), + // format is decoded and written without it. The color space is read from the header's bytes + // 16..20 even in a profile cut short, as the PDF writer reads it. + let strip = crate::images::source_icc(&bytes).is_some_and(|icc| match icc.get(16..20) { + Some(space @ (b"GRAY" | b"RGB ")) => !profiles || <[u8; 4]>::try_from(space).is_ok_and(|s| !crate::images::valid_icc(&icc, s)), _ => false, }); let direct = match blob.mime.as_str() { diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 1f8e3e80d6..400bc0b0a8 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -379,14 +379,16 @@ fn image_data(pdf: &[u8]) -> Vec> { /// A PNG or JPEG embedded as it is whose gray or RGB profile is malformed is written without it, /// the JPEG with its JPEG data unchanged: a profile whose header declares another size, one without -/// the `acsp` signature, and one whose tag table runs past its end. +/// the `acsp` signature, one whose tag table runs past its end, and one cut short after 64 bytes. #[test] fn a_malformed_profile_is_left_out_of_a_png_or_jpeg_kept_as_it_is() { for (space, img, device) in [(b"RGB ", colour(), "DeviceRGB"), (b"GRAY", colour().grayscale(), "DeviceGray")] { - let mut malformed = [icc_profile(space, 600), icc_profile(space, 600), icc_profile(space, 600)]; + let mut malformed = [icc_profile(space, 600), icc_profile(space, 600), icc_profile(space, 600), icc_profile(space, 600)]; malformed[0][..4].copy_from_slice(&601u32.to_be_bytes()); malformed[1][36..40].copy_from_slice(b"xxxx"); malformed[2][128..132].copy_from_slice(&100u32.to_be_bytes()); + malformed[3].truncate(64); + malformed[3][..4].copy_from_slice(&64u32.to_be_bytes()); for (i, icc) in malformed.iter().enumerate() { for (format, mime, filter) in [(ImageFormat::Png, "image/png", "FlateDecode"), (ImageFormat::Jpeg, "image/jpeg", "DCTDecode")] { let what = format!("{device}, {format:?}, malformed profile {i}"); From 025711215832ffbdc079c523da8fef4dacc34ae9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:16:35 -0800 Subject: [PATCH 70/82] TIFF: JPEG strips larger than their strip and oversized strip tables are refused before decoding oversized_tiff now also refuses a TIFF whose JPEG-compressed strip or tile (Compression 7) declares a frame wider or taller than the strip or tile, one with more than MAX_TIFF_CHUNKS strips or tiles, and one whose JPEG headers take more than 16,777,216 steps to read. It reads BigTIFF directories, follows the layout and the repeated tags the decoder reads, and refuses offsets of types it does not read. --- crates/doc/src/cmyk.rs | 503 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 466 insertions(+), 37 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 8cd3f85a4f..77454f64a1 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -56,19 +56,114 @@ fn is_tiff(bytes: &[u8]) -> bool { bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") } -/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than -/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane -/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2) -/// has a plane for each channel; other files have one. The decoder multiplies these counts without -/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel -/// wide, claims at least 2^32 samples. -/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images +/// What [`jpeg_frames`] found in a JPEG's headers. +enum Frames { + /// Every frame header declares a size the check accepts, or there is none. + Fit, + /// The width and height of the first frame header the check refuses. + Misfit(u16, u16), + /// The steps ran past [`MAX_JPEG_HEADER_STEPS`]. + OverBudget, +} + +/// Checks the width and height each 8-bit baseline, extended or progressive frame header in the +/// headers of a JPEG declares with `fits`. The JPEG is the bytes after its start-of-image marker: +/// `parts[0]` followed by `parts[1]`. The headers are read as the decoder reads them, up to the +/// first start-of-scan or end-of-image marker: every other marker is followed by its segment's +/// length, and bytes between segments are passed over, as are fill bytes (0xFF, and 0x00 after +/// one) before a marker. `steps` counts each byte looked at and each segment passed over. +fn jpeg_frames(parts: [&[u8]; 2], steps: &mut u64, fits: impl Fn(u16, u16) -> bool) -> Frames { + let byte = |i: usize| match i.checked_sub(parts[0].len()) { + None => parts[0].get(i).copied(), + Some(j) => parts[1].get(j).copied(), + }; + let u16_at = |i: usize| Some(u16::from_be_bytes([byte(i)?, byte(i.checked_add(1)?)?])); + let mut p = 0usize; + loop { + *steps += 1; + if *steps > MAX_JPEG_HEADER_STEPS { + return Frames::OverBudget; + } + let Some(b) = byte(p) else { return Frames::Fit }; + p += 1; + if b != 0xFF { + continue; + } + while matches!(byte(p), Some(0xFF | 0)) { + p += 1; + *steps += 1; + if *steps > MAX_JPEG_HEADER_STEPS { + return Frames::OverBudget; + } + } + let Some(marker) = byte(p) else { return Frames::Fit }; + p += 1; + if matches!(marker, 0xDA | 0xD9) { + return Frames::Fit; + } + // Length (2 bytes), then for a frame header its precision, height and width. + let Some(len) = u16_at(p) else { return Frames::Fit }; + if matches!(marker, 0xC0..=0xC2) + && byte(p.saturating_add(2)) == Some(8) + && let (Some(h), Some(w)) = (u16_at(p.saturating_add(3)), u16_at(p.saturating_add(5))) + && !fits(w, h) + { + return Frames::Misfit(w, h); + } + if len < 2 { + return Frames::Fit; + } + p = p.saturating_add(usize::from(len)); + } +} + +/// Most strips or tiles the first image of a TIFF may have: VectorCraft's own limit, 1/1024 of +/// [`crate::MAX_RASTER_ALLOC`] (524,288 in the web app, 2,097,152 on the desktop). The decoder +/// reads an offset and a byte count of 8 bytes each for every one into memory before anything else. +pub const MAX_TIFF_CHUNKS: u64 = crate::MAX_RASTER_ALLOC / 1024; + +/// Most pixels the JPEG of a JPEG-compressed TIFF tile may declare when it declares more pixels +/// than the whole image has: VectorCraft's own limit (2048 × 2048, 16 MB as CMYK). +pub const MAX_JPEG_TILE_PIXELS: u64 = 1 << 22; + +/// Most steps reading the JPEG headers of a TIFF's strips or tiles may take (a byte looked at, a +/// segment passed over or a strip offset read): VectorCraft's own limit. +const MAX_JPEG_HEADER_STEPS: u64 = 1 << 24; + +/// The width and height of the first image of TIFF or BigTIFF `bytes` when it has more strips or +/// tiles than the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles +/// per plane times planes past `usize::MAX`. A file that stores one plane per channel +/// (PlanarConfiguration 2) has a plane for each channel; other files have one. The decoder +/// multiplies these counts without a check, which panics where overflow checks are on. Such an +/// image, when it is at least a pixel wide, claims at least 2^32 samples. +/// It also gives them for an image with more than [`MAX_TIFF_CHUNKS`] strips or tiles, counted from +/// its size or from the number of values its StripOffsets and StripByteCounts tags hold, or its +/// TileOffsets and TileByteCounts tags for a tiled image. An image the decoder reads as strips (it +/// has both strip tables and neither tile table) counts strips, and one it reads as tiles counts +/// tiles; the decoder refuses an image with any other mix of these tags before it reads a table. +/// For a JPEG-compressed image (Compression 7) it gives the width and height a strip's or tile's +/// JPEG declares in a frame header (in the JPEGTables, or in the strip or tile before its first +/// scan) when they are wider or taller than the strip or tile, or, for a tile, when they make more +/// than [`MAX_JPEG_TILE_PIXELS`] pixels and more than the image has. The decoder allocates the +/// pixels the JPEG declares. It gives the image's width and height when reading those headers +/// takes more than 16,777,216 steps (a byte looked at, a segment passed over or a strip offset +/// read), when the strip or tile offsets are of a type other than BYTE, UNDEFINED, SHORT, LONG, +/// IFD, LONG8 or IFD8, and when the image's width and height, a striped image's RowsPerStrip or a +/// tiled image's tile size isn't a single value of one of those types. +/// Where a tag repeats, the value giving the most strips or tiles counts, and the JPEG check uses +/// the last entry of a field type the decoder knows, as the decoder does. `None` for other images /// and for bytes that aren't a TIFF. pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> { - let le = bytes.starts_with(b"II*\0"); - if !le && !bytes.starts_with(b"MM\0*") { + let le = bytes.starts_with(b"II"); + let head = bytes.get(..4)?; + // A BigTIFF counts and points with 8 bytes where a TIFF uses 4, so its entries are 20 bytes. + let big = if head == b"II*\0" || head == b"MM\0*" { + false + } else if head == b"II+\0" || head == b"MM\0+" { + true + } else { return None; - } + }; let u16_at = |at: usize| -> Option { let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?; Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) }) @@ -77,32 +172,71 @@ pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> { let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?; Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }) }; - let ifd = usize::try_from(u32_at(4)?).ok()?; - // (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its - // single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points) - let entry = |i: usize| -> Option<(u16, Option)> { - let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?; - let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?); - let value = match (ty, count) { - (1, 1) => bytes.get(at).map(|&b| u32::from(b)), - (3, 1) => u16_at(at).map(u32::from), - (4 | 13, 1) => u32_at(at), - (16 | 18, 1) => { - let at = usize::try_from(u32_at(at)?).ok()?; - let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?); - let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) }; - u32::try_from(wide).ok() - } + let u64_at = |at: usize| -> Option { + let b: [u8; 8] = bytes.get(at..at.checked_add(8)?)?.try_into().ok()?; + Some(if le { u64::from_le_bytes(b) } else { u64::from_be_bytes(b) }) + }; + let word_len: usize = if big { 8 } else { 4 }; + let word = |at: usize| if big { u64_at(at) } else { u32_at(at).map(u64::from) }; + // The bytes one value of each field type takes; 0 for types the decoder doesn't know. + let width_of = |ty: u16| -> usize { + match ty { + 1 | 2 | 6 | 7 => 1, + 3 | 8 => 2, + 4 | 9 | 11 | 13 => 4, + 5 | 10 | 12 | 16..=18 => 8, + _ => 0, + } + }; + let ifd = usize::try_from(word(if big { 8 } else { 4 })?).ok()?; + let entries = if big { u64_at(ifd)? } else { u64::from(u16_at(ifd)?) }; + let first = ifd.checked_add(if big { 8 } else { 2 })?; + // (tag, field type, number of values, where the values are: in the entry when they fit in its + // last word, otherwise where that word points) + let entry = |i: usize| -> Option<(u16, u16, u64, Option)> { + let e = first.checked_add(i.checked_mul(4 + 2 * word_len)?)?; + let (tag, ty, count) = (u16_at(e)?, u16_at(e.checked_add(2)?)?, word(e.checked_add(4)?)?); + let at = e.checked_add(4 + word_len)?; + let fits = u64::try_from(width_of(ty)).ok()?.checked_mul(count).is_some_and(|n| n <= word_len as u64); + let values = if fits { Some(at) } else { word(at).and_then(|p| usize::try_from(p).ok()) }; + Some((tag, ty, count, values)) + }; + // Value `i` of an entry's BYTE, UNDEFINED, SHORT, LONG, IFD, LONG8 or IFD8 values at `at`. + let nth = |ty: u16, at: usize, i: u64| -> Option { + let p = at.checked_add(usize::try_from(i).ok()?.checked_mul(width_of(ty))?)?; + match ty { + 1 | 7 => bytes.get(p).map(|&b| u64::from(b)), + 3 => u16_at(p).map(u64::from), + 4 | 13 => u32_at(p).map(u64::from), + 16 | 18 => u64_at(p), _ => None, - }; - Some((u16_at(e)?, value)) + } }; let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false); let (mut rows, mut tile_w, mut tile_h) = (None::, None::, None::); + // The most values a strip table and a tile table hold. + let (mut strip_chunks, mut tile_chunks) = (0u64, 0u64); + // The decoder keeps the last entry of a tag whose field type it knows. These are the last such + // entries (type, count, where) of the tags `LAST` lists. + const LAST: [u16; 11] = [256, 257, 259, 278, 322, 323, 273, 279, 324, 325, 347]; + let mut last = [None::<(u16, u64, Option)>; LAST.len()]; let least = |was: Option, v: u32| Some(was.map_or(v, |w| w.min(v))); - for i in 0..usize::from(u16_at(ifd)?) { - let Some((tag, value)) = entry(i) else { break }; - let Some(v) = value else { continue }; + // The entry's single BYTE, UNDEFINED, SHORT, LONG or IFD value, or its single LONG8 or IFD8 + // value when that fits in 32 bits. + let single = |ty: u16, count: u64, at: Option| at.filter(|_| count == 1).and_then(|at| nth(ty, at, 0)).and_then(|v| u32::try_from(v).ok()); + for i in 0..usize::try_from(entries).unwrap_or(usize::MAX) { + let Some((tag, ty, count, at)) = entry(i) else { break }; + match tag { + 273 | 279 => strip_chunks = strip_chunks.max(count), + 324 | 325 => tile_chunks = tile_chunks.max(count), + _ => {} + } + if width_of(ty) > 0 + && let Some(slot) = LAST.iter().position(|&t| t == tag).and_then(|k| last.get_mut(k)) + { + *slot = Some((ty, count, at)); + } + let Some(v) = single(ty, count, at) else { continue }; match tag { 256 => width = width.max(v), 257 => height = height.max(v), @@ -115,13 +249,87 @@ pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> { } } let planes = if planar { u128::from(planes) } else { 1 }; - let tiles = match (tile_w, tile_h) { - (Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128, - _ => false, + let tiled = match (tile_w, tile_h) { + (Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes, + _ => 0, }; + let tiles = tiled > usize::MAX as u128; let rows = rows.unwrap_or(height); - let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX); - (tiles || strips).then_some((width, height)) + let striped = height.saturating_sub(1).checked_div(rows).map_or(0, |n| u128::from(n + 1) * planes); + let strips = striped > u128::from(u32::MAX); + if tiles || strips { + return Some((width, height)); + } + let last_of = |tag: u16| LAST.iter().position(|&t| t == tag).and_then(|k| last.get(k).copied().flatten()); + // The single value the decoder reads from the last entry of `tag`: `None` without one, and + // `Some(None)` when that entry doesn't hold a single value read here. + let value_of = |tag: u16| last_of(tag).map(|(ty, count, at)| single(ty, count, at)); + // The decoder reads strips when the image has both strip tables and neither tile table, tiles + // in the opposite case, and refuses every other image before it reads a table. + let layout = match [273, 279, 324, 325].map(|tag| last_of(tag).is_some()) { + [true, true, false, false] => Some((273, striped.max(u128::from(strip_chunks)))), + [false, false, true, true] => Some((324, tiled.max(u128::from(tile_chunks)))), + _ => None, + }; + let (offsets_tag, chunks) = layout?; + if chunks > u128::from(MAX_TIFF_CHUNKS) { + return Some((width, height)); + } + if value_of(259) != Some(Some(7)) { + return None; + } + // The JPEG check uses the sizes the decoder reads. A size it needs that isn't a single value + // read here refuses the image. + let (Some(Some(w_img)), Some(Some(h_img))) = (value_of(256), value_of(257)) else { return Some((width, height)) }; + let tile = offsets_tag == 324; + // RowsPerStrip, which the decoder reads only for strips. + let rows = if tile { Some(h_img) } else { value_of(278).unwrap_or(Some(h_img)) }; + let Some(rows) = rows else { return Some((width, height)) }; + let (tw, th) = match (value_of(322).flatten(), value_of(323).flatten()) { + (Some(tw), Some(th)) => (tw, th), + _ if tile => return Some((width, height)), + _ => (0, 0), + }; + // A strip is as wide as the image and RowsPerStrip tall (at most the image's height); a tile's + // JPEG may be as large as the tile, which the edge tiles of the image overhang. + let fits = |w: u16, h: u16| { + let (w, h) = (u32::from(w), u32::from(h)); + if tile { + let pixels = u64::from(w) * u64::from(h); + w <= tw && h <= th && (pixels <= MAX_JPEG_TILE_PIXELS || pixels <= u64::from(w_img) * u64::from(h_img)) + } else { + w <= w_img && h <= rows.min(h_img) + } + }; + // The decoder reads the JPEGTables after their start-of-image marker and without their last two + // bytes, followed by each strip or tile after its first two bytes; without JPEGTables, each + // strip or tile after its start-of-image marker. + let tables = last_of(347) + .and_then(|(_, count, at)| { + let at = at?; + let end = at.checked_add(usize::try_from(count).ok()?)?.min(bytes.len()); + bytes.get(at.checked_add(2)?..end.checked_sub(2)?) + }) + .unwrap_or_default(); + let (ty, count, at) = last_of(offsets_tag)?; + let at = at?; + // The decoder also takes offsets of other types (a RATIONAL or ASCII value), which aren't read + // here: such an image is refused. + if !matches!(ty, 1 | 3 | 4 | 7 | 13 | 16 | 18) { + return Some((width, height)); + } + let mut steps = 0u64; + for i in 0..count { + steps += 1; + let Some(at) = nth(ty, at, i).and_then(|o| usize::try_from(o).ok()) else { break }; + let part = bytes.get(at.saturating_add(2)..).unwrap_or_default(); + match jpeg_frames([tables, part], &mut steps, fits) { + Frames::Fit => {} + Frames::Misfit(w, h) => return Some((u32::from(w), u32::from(h))), + Frames::OverBudget => return Some((width, height)), + } + } + None } /// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too. @@ -891,7 +1099,7 @@ mod tests { f }; assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX))); - assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits"); + assert_eq!(oversized_tiff(&header(1)), Some((u32::MAX, u32::MAX)), "one plane: countable, but past MAX_TIFF_CHUNKS"); assert!(tiff(&header(2)).is_none(), "not decoded"); } @@ -933,6 +1141,227 @@ mod tests { } } + /// A little-endian TIFF, or BigTIFF when `big`, holding `data` right after its header (from + /// byte 8, or 16 in a BigTIFF), then one directory of `entries` (tag, field type, values). An + /// entry's values sit in the entry when they fit, otherwise after the directory. + fn tiff_file(big: bool, entries: &[(u16, u16, Vec)], data: &[u8]) -> Vec { + let (word, head) = if big { (8, 16) } else { (4, 8) }; + let put = |f: &mut Vec, v: u64, n: usize| f.extend(&v.to_le_bytes()[..n]); + let mut f = if big { b"II+\0\x08\0\0\0".to_vec() } else { b"II*\0".to_vec() }; + let ifd = head + data.len(); + put(&mut f, ifd as u64, word); + f.extend(data); + put(&mut f, entries.len() as u64, if big { 8 } else { 2 }); + let after = f.len() + entries.len() * (4 + 2 * word) + word; + let mut tail = vec![]; + for (tag, ty, values) in entries { + let width = match ty { + 1 | 7 => 1, + 3 => 2, + 4 => 4, + _ => 8, + }; + f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat()); + put(&mut f, values.len() as u64, word); + let mut v = vec![]; + for &x in values { + put(&mut v, x, width); + } + if v.len() <= word { + v.resize(word, 0); + f.extend(v); + } else { + put(&mut f, (after + tail.len()) as u64, word); + tail.extend(v); + } + } + put(&mut f, 0, word); + f.extend(tail); + f + } + + /// A JPEG of four components whose progressive frame header declares `w` × `h` pixels, with a + /// scan header and no scan data. + fn jpeg_header(w: u16, h: u16) -> Vec { + let mut j = vec![0xFF, 0xD8, 0xFF, 0xC2, 0, 20, 8]; + j.extend(h.to_be_bytes()); + j.extend(w.to_be_bytes()); + j.push(4); + for id in 1..=4 { + j.extend([id, 0x11, 0]); + } + j.extend([0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0, 0xFF, 0xD9]); + j + } + + /// The directory of a 1 × 1 CMYK image with alpha, JPEG-compressed in one strip of `len` bytes + /// at byte `at`. + fn jpeg_strip_entries(at: u64, len: u64) -> Vec<(u16, u16, Vec)> { + vec![ + (256, 4, vec![1]), + (257, 4, vec![1]), + (258, 3, vec![8; 5]), + (259, 3, vec![7]), + (262, 3, vec![5]), + (273, 4, vec![at]), + (277, 3, vec![5]), + (278, 4, vec![1]), + (279, 4, vec![len]), + (338, 3, vec![2]), + ] + } + + /// A strip's JPEG that declares more pixels than the strip holds would have the decoder + /// allocate them: a 1 × 1 image whose JPEG claims 16384 × 16384 is refused before any decoder + /// opens it, in a TIFF or a BigTIFF, with fill bytes or other bytes before the frame header, + /// and with the frame header in the JPEGTables. + #[test] + fn a_jpeg_strip_declaring_more_than_its_strip_is_refused() { + let big = jpeg_header(16384, 16384); + let file = tiff_file(false, &jpeg_strip_entries(8, big.len() as u64), &big); + assert_eq!(oversized_tiff(&file), Some((16384, 16384))); + assert!(tiff(&file).is_none(), "not decoded"); + let bigtiff = tiff_file(true, &jpeg_strip_entries(16, big.len() as u64), &big); + assert_eq!(oversized_tiff(&bigtiff), Some((16384, 16384)), "BigTIFF"); + // The decoder reads the last ImageWidth and ImageLength: 1 × 1 after 16384 × 16384, in one + // strip without RowsPerStrip. + let mut entries = jpeg_strip_entries(8, big.len() as u64); + entries.retain(|e| e.0 != 278); + entries.splice(0..0, [(256, 4, vec![16384]), (257, 4, vec![16384])]); + assert_eq!(oversized_tiff(&tiff_file(false, &entries, &big)), Some((16384, 16384)), "repeated sizes"); + // StripOffsets as a BYTE, which the decoder reads, and as a RATIONAL, which it reads as two + // offsets. + for (ty, expected) in [(1, (16384, 16384)), (5, (1, 1))] { + let mut entries = jpeg_strip_entries(8, big.len() as u64); + for e in &mut entries { + if e.0 == 273 { + e.1 = ty; + } + } + assert_eq!(oversized_tiff(&tiff_file(false, &entries, &big)), Some(expected), "StripOffsets of type {ty}"); + } + let one = jpeg_header(1, 1); + assert_eq!(oversized_tiff(&tiff_file(false, &jpeg_strip_entries(8, one.len() as u64), &one)), None, "a JPEG as large as its strip"); + let wide = jpeg_header(2, 1); + assert_eq!(oversized_tiff(&tiff_file(false, &jpeg_strip_entries(8, wide.len() as u64), &wide)), Some((2, 1)), "one pixel too wide"); + for (what, prefix) in [("fill bytes", &[0xFF, 0x00, 0xFF][..]), ("other bytes", &[0x12, 0x34][..])] { + let mut j = big[..2].to_vec(); + j.extend(prefix); + j.extend(&big[2..]); + let file = tiff_file(false, &jpeg_strip_entries(8, j.len() as u64), &j); + assert_eq!(oversized_tiff(&file), Some((16384, 16384)), "{what} before the frame header"); + } + // The JPEGTables hold the frame header; the strip holds only the scan. + let tables = [&big[..24], &[0xFF, 0xD9][..]].concat(); + let scan = [&[0xFF, 0xD8][..], &big[24..]].concat(); + let mut entries = jpeg_strip_entries(8 + tables.len() as u64, scan.len() as u64); + entries.push((347, 7, tables.iter().map(|&b| u64::from(b)).collect())); + let file = tiff_file(false, &entries, &[tables, scan].concat()); + assert_eq!(oversized_tiff(&file), Some((16384, 16384)), "frame header in the JPEGTables"); + // An encoder's 8 × 8 RGB JPEG, with its own segments before the frame header, in an + // 8 × 8 image of one strip, and of strips 4 rows tall. + let mut real = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut real).encode(&[90; 8 * 8 * 3], 8, 8, image::ExtendedColorType::Rgb8).unwrap(); + let rgb = |rows: u64| { + let entries = [ + (256, 4, vec![8]), + (257, 4, vec![8]), + (258, 3, vec![8; 3]), + (259, 3, vec![7]), + (262, 3, vec![2]), + (273, 4, vec![8]), + (277, 3, vec![3]), + (278, 4, vec![rows]), + (279, 4, vec![real.len() as u64]), + ]; + oversized_tiff(&tiff_file(false, &entries, &real)) + }; + assert_eq!(rgb(8), None, "an encoder's JPEG as large as its strip"); + assert_eq!(rgb(4), Some((8, 8)), "an encoder's JPEG taller than its strip"); + } + + /// A tile's JPEG may be as large as the tile, which overhangs the image, and when it is more + /// than MAX_JPEG_TILE_PIXELS, no larger than the image. + #[test] + fn a_jpeg_tile_declaring_more_than_its_tile_is_refused() { + let tiled_with = |tile: u64, j: &[u8], rows: Vec| { + let mut entries: Vec<_> = jpeg_strip_entries(0, 0).into_iter().filter(|e| !matches!(e.0, 273 | 278 | 279)).collect(); + if !rows.is_empty() { + entries.push((278, 4, rows)); + } + entries.extend([(322, 4, vec![tile]), (323, 4, vec![tile]), (324, 4, vec![8]), (325, 4, vec![j.len() as u64])]); + oversized_tiff(&tiff_file(false, &entries, j)) + }; + let tiled = |tile: u64, j: &[u8]| tiled_with(tile, j, vec![]); + assert_eq!(tiled_with(16, &jpeg_header(16, 16), vec![1, 1]), None, "RowsPerStrip, unread in a tiled image"); + assert_eq!(tiled(16, &jpeg_header(16, 16)), None, "as large as the tile"); + assert_eq!(tiled(16, &jpeg_header(17, 16)), Some((17, 16)), "wider than the tile"); + assert_eq!(tiled(16, &jpeg_header(16, 17)), Some((16, 17)), "taller than the tile"); + assert_eq!(tiled(4096, &jpeg_header(2048, 2048)), None, "MAX_JPEG_TILE_PIXELS"); + assert_eq!(tiled(4096, &jpeg_header(2048, 2049)), Some((2048, 2049)), "past MAX_JPEG_TILE_PIXELS and the image"); + } + + /// The decoder reads every strip's offset and byte count before anything else: an image with + /// more than MAX_TIFF_CHUNKS strips, counted from its size or from its tables, is refused. + #[test] + fn tiffs_with_more_strips_than_their_tables_may_hold_are_refused() { + let file = |height: u64, count: u32| { + let entries = [ + (256, 4, vec![1]), + (257, 4, vec![height]), + (258, 3, vec![8]), + (262, 3, vec![1]), + (273, 4, vec![8]), + (278, 4, vec![1]), + (279, 4, vec![1]), + ]; + let mut f = tiff_file(false, &entries, &[0]); + // StripOffsets' count, its values left out of the file. + let at = 8 + 1 + 2 + 4 * 12 + 4; + f[at..at + 4].copy_from_slice(&count.to_le_bytes()); + f + }; + let most = MAX_TIFF_CHUNKS; + assert_eq!(oversized_tiff(&file(most, 1)), None, "MAX_TIFF_CHUNKS rows of a strip each"); + assert_eq!(oversized_tiff(&file(most + 1, 1)), Some((1, u32::try_from(most + 1).unwrap())), "one strip more"); + assert_eq!(oversized_tiff(&file(1, u32::try_from(most).unwrap())), None, "MAX_TIFF_CHUNKS offsets"); + assert_eq!(oversized_tiff(&file(1, u32::try_from(most + 1).unwrap())), Some((1, 1)), "one offset more"); + assert!(tiff(&file(1, 1 << 25)).is_none(), "not decoded"); + // Tile sizes without tile tables: the decoder reads the strips and never counts the + // 4,194,304 tiles of a pixel the sizes give. + let strips_with_tile_sizes = |offsets: Option| { + let mut entries = vec![ + (256, 4, vec![2048]), + (257, 4, vec![2048]), + (258, 3, vec![8]), + (262, 3, vec![1]), + (273, 4, vec![8]), + (279, 4, vec![2048 * 2048]), + (322, 4, vec![1]), + (323, 4, vec![1]), + ]; + entries.extend(offsets.map(|tag| (tag, 4, vec![8]))); + entries.sort_by_key(|e| e.0); + oversized_tiff(&tiff_file(false, &entries, &[0])) + }; + assert_eq!(strips_with_tile_sizes(None), None, "a strip image with tile sizes of one pixel"); + assert_eq!(strips_with_tile_sizes(Some(324)), None, "strip and tile tables: the decoder refuses it unread"); + } + + /// Strips whose JPEG headers take more than MAX_JPEG_HEADER_STEPS steps to read refuse the + /// file: here every strip points at the same 1 MB without a marker. + #[test] + fn jpeg_strips_that_take_too_long_to_read_are_refused() { + let file = |strips: u64| { + let mut entries = jpeg_strip_entries(0, 0); + entries.retain(|e| !matches!(e.0, 257 | 273 | 279)); + entries.extend([(257, 4, vec![strips]), (273, 4, vec![8; strips as usize]), (279, 4, vec![1 << 20; strips as usize])]); + tiff_file(false, &entries, &std::iter::repeat_n(0, 1 << 20).collect::>()) + }; + assert_eq!(oversized_tiff(&file(8)), None, "about 8 million steps"); + assert_eq!(oversized_tiff(&file(17)), Some((1, 17)), "about 17 million steps"); + } + fn inks() -> Inks { Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap() } From 9fb5abfbc6dab30a81516c28fac89fb8259b87fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:28:34 -0800 Subject: [PATCH 71/82] Export: hidden PDF layers place symbol art effects when a long page raises the version A PDF/A-2b export at PDF 1.4 with Create PDF Layers and a page longer than 14,400 pt is written as PDF 1.6, which has PDF layers. The export decided which layers place their instances' symbol art before it raised the version, so a hidden layer, written as a PDF layer that is off, kept its instances' art without the art's live effects. The version a long page needs is now settled before the document is baked. The pages come from the artboards, which baking leaves as they are. --- crates/pdf/src/export/entry.rs | 8 +++++--- crates/pdf/src/tests/layers.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 6cf8677269..00ac16a3fe 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -45,6 +45,11 @@ 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)?; + // The pages come from the artboards, which baking leaves as they are. The version a long page + // raises the file to decides whether the layers are written, so it is settled before baking. + let pages = pages(doc, opts)?; + let raised = for_long_pages(set, &pages)?; + let set = raised.as_ref().unwrap_or(set); // Live geometry effects export as their result, in symbols' art too; raster effects are // reported below. // Only the layers this export writes place their instances' art (see `ex.non_printing` and @@ -57,9 +62,6 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // 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); - let pages = pages(doc, opts)?; - let raised = for_long_pages(set, &pages)?; - let set = raised.as_ref().unwrap_or(set); // The file's Title is File Info's; the page information names the document, as its tab does. let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned())); diff --git a/crates/pdf/src/tests/layers.rs b/crates/pdf/src/tests/layers.rs index 0650e3f0ef..b11cbc47d8 100644 --- a/crates/pdf/src/tests/layers.rs +++ b/crates/pdf/src/tests/layers.rs @@ -131,6 +131,37 @@ fn pdf_layers_import_back_as_layers() { } } +/// A hidden layer written as a PDF layer places the live effects of its instances' symbol art, +/// also in a PDF/A-2b file at PDF 1.4 whose 17,000 pt page has it written as PDF 1.6: the symbol's +/// square from x = 10 to 40, which a Transform effect moves 100 pt right, is written from x = 110 +/// to 140. +#[test] +fn a_hidden_layer_places_symbol_art_effects_when_a_long_page_raises_the_version() { + let mut d = Document::new(17_000.0, 300.0); + d.layers.clear(); + let mut square = rect(&mut d, 10.0, RED); + square.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"moveH": 100.0}), visible: true }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: square.into() }); + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut l = Node::layer(d.alloc_id(), "Hidden", LayerColor::Preset(0)); + l.visible = false; + if let NodeKind::Layer { children, .. } = &mut l.kind { + children.push(instance.into()); + } + d.layers.push(l.into()); + for compatibility in [Compatibility::Pdf14, Compatibility::Pdf16] { + let settings = PdfSettings { standard: Standard::PdfA2b, compatibility, create_layers: true, ..PdfSettings::default() }; + let r = export_with_report(&d, &PdfOptions { settings, created: Some(1_791_200_000), ..Default::default() }).unwrap(); + assert!(r.bytes.starts_with(b"%PDF-1.6"), "{compatibility:?}"); + let back = import_with_report(&r.bytes, &ImportOptions::default()).unwrap().document; + let hidden = back.layers.iter().find(|l| l.name.as_deref() == Some("Hidden")).expect("the hidden layer"); + let art = hidden.children().unwrap(); + assert_eq!(art.len(), 1, "{compatibility:?}"); + let b = art[0].geometric_bounds().unwrap(); + assert!((b.x0 - 110.0).abs() < 0.1 && (b.x1 - 140.0).abs() < 0.1, "{compatibility:?}: {b:?}"); + } +} + /// The rows of `nodes` in paint order, depth first: `depth name visible` for layers, `depth fill` /// for art. fn tree(nodes: &[std::sync::Arc], depth: usize, out: &mut Vec) { From f4a5d65f6b31edd048a1f9c2c95f49b00b45dff0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:55:52 -0800 Subject: [PATCH 72/82] Type: Glyphs panel menu draws the selected characters in an East Asian glyph form With Show East Asian Options on, the Glyphs panel menu lists Traditional Forms, Expert Forms, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms. Each form draws the selected characters with the font's OpenType feature for it (trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid). A form is checked when every selected character has it and disabled when none of the faces that draw the selected text, fallback faces included, has the feature. The form is a new character attribute, glyphForm, one form at a time (text.setFormat, text.setRangeStyle). It is saved only when set, character styles carry it, and runs that differ only in their form shape as one piece of text. SVG writes it in font-feature-settings; PDF draws the glyphs from the layout. --- crates/doc/src/lib.rs | 1 + crates/doc/src/text.rs | 4 +- crates/doc/src/text/char_style.rs | 94 +++++++++++ crates/engine/src/cmd/panelcmds.rs | 7 +- crates/engine/src/cmd/textedit.rs | 22 ++- crates/engine/src/cmd/textstyles.rs | 1 + crates/engine/src/cmd/typecmd/area_tests.rs | 49 ++++++ crates/svg/src/css.rs | 4 + crates/text/src/features.rs | 3 + crates/text/src/fontdb/gsub.rs | 14 ++ crates/text/src/harakat.rs | 1 + crates/text/src/shape.rs | 10 +- crates/text/src/tests/typo.rs | 37 +++++ crates/ui-egui/src/panels/character.rs | 14 ++ crates/ui-egui/src/panels/glyphs.rs | 163 +++++++++++++++++++- docs/mcp.md | 21 +++ docs/target-app-parity.md | 1 + docs/type-parity.md | 1 + 18 files changed, 438 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c286492236..9389bdc965 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -118,6 +118,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::FontHeightReference; pub use text::GlyphChoice; +pub use text::GlyphForm; pub use text::WarichuAlign; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index c3c1464cfc..89388d8598 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -18,8 +18,8 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; pub use char_style::{ - CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, - WARICHU_SCALE, WarichuAlign, + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, GlyphForm, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, + WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index ef3cf29dc9..97f744dba8 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -205,6 +205,99 @@ pub struct CharStyle { /// default glyph otherwise. Saved only when set. #[serde(default, rename = "glyphAlternate", skip_serializing_if = "Option::is_none")] pub glyph_alternate: Option, + /// The East Asian glyph form these characters are drawn in (Glyphs panel menu, with Show East + /// Asian Options): one OpenType feature of the font, such as JIS 78 forms (`jp78`), or the + /// font's default glyphs. The text keeps its characters. Saved only when set. + /// + #[serde(default, rename = "glyphForm", skip_serializing_if = "crate::skip::is_default")] + pub glyph_form: GlyphForm, +} + +/// The East Asian glyph form characters are drawn in ([`CharStyle::glyph_form`]): the font's +/// default glyphs, or the glyphs one OpenType feature substitutes. One form at a time. +/// +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GlyphForm { + /// The font's default glyphs. + #[default] + Default, + /// Traditional forms (`trad`). + Traditional, + /// Expert forms (`expt`). + Expert, + /// JIS 2004 forms (`jp04`). + Jis04, + /// JIS 90 forms (`jp90`). + Jis90, + /// JIS 78 forms (`jp78`). + Jis78, + /// JIS 83 forms (`jp83`). + Jis83, + /// Monospaced full-width forms (`fwid`). + FullWidth, + /// Monospaced half-width forms (`hwid`). + HalfWidth, + /// Monospaced third-width forms (`twid`). + ThirdWidth, + /// Monospaced quarter-width forms (`qwid`). + QuarterWidth, +} + +impl GlyphForm { + /// Every form, in menu order, the default last. + pub const ALL: [GlyphForm; 11] = [ + GlyphForm::Traditional, + GlyphForm::Expert, + GlyphForm::Jis04, + GlyphForm::Jis90, + GlyphForm::Jis78, + GlyphForm::Jis83, + GlyphForm::FullWidth, + GlyphForm::HalfWidth, + GlyphForm::ThirdWidth, + GlyphForm::QuarterWidth, + GlyphForm::Default, + ]; + + /// The name files and commands give this form (as it is saved). + pub fn id(self) -> &'static str { + match self { + GlyphForm::Default => "default", + GlyphForm::Traditional => "traditional", + GlyphForm::Expert => "expert", + GlyphForm::Jis04 => "jis04", + GlyphForm::Jis90 => "jis90", + GlyphForm::Jis78 => "jis78", + GlyphForm::Jis83 => "jis83", + GlyphForm::FullWidth => "fullWidth", + GlyphForm::HalfWidth => "halfWidth", + GlyphForm::ThirdWidth => "thirdWidth", + GlyphForm::QuarterWidth => "quarterWidth", + } + } + + /// The form [`GlyphForm::id`] names; `None` for any other name. + pub fn from_id(id: &str) -> Option { + GlyphForm::ALL.into_iter().find(|f| f.id() == id) + } + + /// The OpenType feature that draws this form; `None` for the default glyphs. + pub fn feature(self) -> Option<&'static [u8; 4]> { + Some(match self { + GlyphForm::Default => return None, + GlyphForm::Traditional => b"trad", + GlyphForm::Expert => b"expt", + GlyphForm::Jis04 => b"jp04", + GlyphForm::Jis90 => b"jp90", + GlyphForm::Jis78 => b"jp78", + GlyphForm::Jis83 => b"jp83", + GlyphForm::FullWidth => b"fwid", + GlyphForm::HalfWidth => b"hwid", + GlyphForm::ThirdWidth => b"twid", + GlyphForm::QuarterWidth => b"qwid", + }) + } } /// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it @@ -586,6 +679,7 @@ impl Default for CharStyle { no_break: false, language: None, glyph_alternate: None, + glyph_form: GlyphForm::Default, } } } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2383d05a3a..b37121ad8f 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -592,6 +592,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "standardVerticalRoman", "noBreak", "digits", + "glyphForm", "fontHeightReference", "language", "fontAxes", @@ -626,6 +627,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let char_align = super::textedit::char_align_param(p, C)?; let char_direction = super::textedit::char_direction_param(p, C)?; let digits = super::textedit::digits_param(p, C)?; + let glyph_form = super::textedit::glyph_form_param(p, C)?; let language = super::textedit::language_param(p, C)?; let font_axes = super::textedit::font_axes_param(p, C)?; let font_height_reference = super::textedit::font_height_reference_param(p, C)?; @@ -796,6 +798,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = digits { st.digits = v; } + if let Some(v) = glyph_form { + st.glyph_form = v; + } if let Some(v) = &language { st.language.clone_from(v); } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 9ed7f3252b..ee843df8dd 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options: the characters are drawn in that form of the font, the OpenType feature trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid, one form at a time; a character the font has no such glyph for keeps its own; \"default\", Revert To Default Forms, draws the font's default glyphs; another value is refused), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -473,6 +473,7 @@ pub(crate) struct CharChange { standard_vertical_roman: Option, no_break: Option, digits: Option, + glyph_form: Option, /// The language to set: a tag, or `None` for the default. language: Option>, font_axes: Option, @@ -571,6 +572,20 @@ pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result Result> { + use vectorcraft_doc::GlyphForm; + let Some(v) = p.get("glyphForm").filter(|v| !v.is_null()) else { return Ok(None) }; + let Some(f) = v.as_str().and_then(GlyphForm::from_id) else { + let ids: Vec = GlyphForm::ALL.iter().map(|f| format!("\"{}\"", f.id())).collect(); + return Err(bad(cmd, format!("`glyphForm` must be one of {}", ids.join(", ")))); + }; + Ok(Some(f)) +} + /// `language: name|tag` (one of Document Setup's languages, case-insensitive) → the language to /// set ([`vectorcraft_doc::CharStyle::language`]: `None` inside for the default). pub(crate) fn language_param(p: &Value, cmd: &str) -> Result>> { @@ -840,6 +855,7 @@ impl CharChange { standard_vertical_roman: flag("standardVerticalRoman"), no_break: flag("noBreak"), digits: digits_param(p, cmd)?, + glyph_form: glyph_form_param(p, cmd)?, language: language_param(p, cmd)?, font_axes: font_axes_param(p, cmd)?, font_height_reference: font_height_reference_param(p, cmd)?, @@ -905,6 +921,7 @@ impl CharChange { && self.standard_vertical_roman.is_none() && self.no_break.is_none() && self.digits.is_none() + && self.glyph_form.is_none() && self.language.is_none() && self.font_axes.is_none() && self.font_height_reference.is_none() @@ -1070,6 +1087,9 @@ impl CharChange { if let Some(v) = self.digits { st.digits = v; } + if let Some(v) = self.glyph_form { + st.glyph_form = v; + } if let Some(v) = self.font_height_reference { st.font_height_reference = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 17d453abf4..18f87d44f7 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -228,6 +228,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("digits", json!(v.digits)), ("fontHeightReference", json!(v.font_height_reference)), ("language", json!(v.language)), + ("glyphForm", json!(v.glyph_form)), ] { m.insert(k.into(), x); } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 36a0f965b7..ca9e4dc2ed 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1925,6 +1925,55 @@ fn standard_vertical_roman_is_a_character_attribute_saved_only_when_on() { assert!(runs(&s).iter().all(|r| !r.style.standard_vertical_roman)); } +/// The East Asian glyph form (Glyphs panel menu) is one character attribute, `glyphForm`: one +/// form replaces another, "default" (Revert To Default Forms) takes it away, and it is saved +/// only when set. A call that changes nothing records no undo step, an unknown form is refused, +/// and character styles carry it. +#[test] +fn glyph_form_is_one_character_attribute_saved_only_when_set() { + use vectorcraft_doc::GlyphForm; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "亜唖芦"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let forms = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.glyph_form)).collect::>(); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("glyphForm").is_none()); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "glyphForm": "jis78"})).unwrap(); + assert_eq!(forms(&s), [("亜".into(), GlyphForm::Default), ("唖".into(), GlyphForm::Jis78), ("芦".into(), GlyphForm::Default)]); + assert_eq!(serde_json::to_value(&runs(&s)[1].style).unwrap()["glyphForm"], "jis78"); + // Saved and reopened as it was. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.runs.iter().map(|r| (r.text.clone(), r.style.glyph_form)).collect::>(), forms(&s)); + // Another form replaces it: one form at a time. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"glyphForm": "fullWidth"})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::FullWidth)); + let n = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"glyphForm": "fullWidth"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing changed, no undo step"); + for bad in [json!("jis2004"), json!(78), json!(true), json!("Traditional")] { + let e = s.execute("text.setFormat", &json!({"glyphForm": bad})).unwrap_err().to_string(); + assert!(e.contains("`glyphForm` must be one of \"traditional\""), "{bad}: {e}"); + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "glyphForm": bad})).is_err(), "{bad}"); + } + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::FullWidth), "a refused call changes nothing"); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style)["glyphForm"], "fullWidth"); + s.execute("text.setFormat", &json!({"glyphForm": "default"})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default)); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("glyphForm").is_none()); + // A character style carries the form. + s.execute("charStyle.new", &json!({"name": "Old forms", "attrs": {"glyphForm": "jis83"}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Old forms", "id": id})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Jis83)); + assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"glyphForm": "jis2004"}})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default)); +} + /// No Break is a character attribute, saved only when on. #[test] fn no_break_is_a_character_attribute_saved_only_when_on() { diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index f7e067aa30..5b0b921e32 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -194,6 +194,10 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { if st.proportional_metrics { v.push("\"palt\" 1".into()); } + // The East Asian glyph form: its one feature. + if let Some(tag) = st.glyph_form.feature().and_then(|t| std::str::from_utf8(t).ok()) { + v.push(format!("\"{tag}\" 1")); + } if !v.is_empty() { p.push(("font-feature-settings", v.join(", "))); } diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 9f2b555e23..112573104d 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -319,6 +319,9 @@ impl OtFeatures { v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1)))); } } + if let Some(tag) = st.glyph_form.feature() { + v.push(f(tag, true)); + } if self.vertical { // Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin // and digits itself (they would lie upside down, and tate-chu-yoko break). diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs index 888c89ab09..bcc19f74b0 100644 --- a/crates/text/src/fontdb/gsub.rs +++ b/crates/text/src/fontdb/gsub.rs @@ -304,6 +304,20 @@ impl FontFace { self.gsub.get_or_init(|| GsubIndex::read(self)) } + /// Does the face's `GSUB` table have feature `tag` with at least one lookup? At most + /// [`MAX_FEATURES`] feature records are read; false when the face has no `GSUB` table or it + /// cannot be read. + pub fn has_substitution_feature(&self, tag: [u8; 4]) -> bool { + let Some(font) = self.skrifa() else { return false }; + let Some(features) = font.gsub().ok().and_then(|g| g.feature_list().ok()) else { return false }; + features + .feature_records() + .iter() + .take(MAX_FEATURES) + .filter(|r| r.feature_tag().to_be_bytes() == tag) + .any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty())) + } + /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, /// `jalt`, `nalt`, `ornm` and `titl` of the face's `GSUB` table substitute for glyph `gid`, in /// that order of features and within a feature in lookup order: the glyph of each single diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index 0f8ce4facd..ab5aa593bd 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -86,6 +86,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { no_break, language, glyph_alternate, + glyph_form: _, fill, stroke, stroke_width, diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index befcbed41f..88d40eb1e8 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -1028,6 +1028,8 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { no_break, language, glyph_alternate, + // An OpenType feature over its own characters, like `features`. + glyph_form: _, } = a; // Keep PartialEq's non-reflexive behavior for ignored numeric fields, without cloning styles. let offsets = (standard_vertical_roman, tcy_up_down, tcy_left_right); @@ -1096,7 +1098,7 @@ pub(crate) fn warichu_alike(a: &CharStyle, b: &CharStyle) -> bool { /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its /// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]), -/// Insert Aki ([`aki`]), Tsume ([`tighten`]), the OpenType features (each run's apply to its own characters, as features +/// Insert Aki ([`aki`]), Tsume ([`tighten`]), the OpenType features and the East Asian glyph form (each run's apply to its own characters, as features /// over a range of the one buffer) and the tracking, as long as the tracking keeps or drops /// standard ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { @@ -1159,6 +1161,8 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { no_break, language, glyph_alternate, + // Applied as a feature over its run's own characters, with `features`. + glyph_form: _, } = a; *font_family == b.font_family && warichu_alike(a, b) @@ -1442,8 +1446,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let mut ranged = Vec::new(); for (r, run_style) in runs.get(source_runs.clone()).unwrap_or_default() { let (a, b) = (r.start.max(range.start), r.end.min(range.end)); - if run_style.features != base_style.features && a < b { - let own = variant(&CharStyle { features: run_style.features.clone(), ..base_style.clone() }); + if (run_style.features != base_style.features || run_style.glyph_form != base_style.glyph_form) && a < b { + let own = variant(&CharStyle { features: run_style.features.clone(), glyph_form: run_style.glyph_form, ..base_style.clone() }); ranged_features(base, &own, a..b, &mut ranged); } } diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 4790b642af..351c698451 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -2041,3 +2041,40 @@ fn soft_return_breaks_line_even_inside_no_break() { let lay = layout(db(), &t); assert_eq!(lay.lines.len(), 2, "soft return honors line break despite no_break"); } + +/// An East Asian glyph form (the Glyphs panel menu's Traditional Forms, JIS 78 Forms, …) draws +/// the characters with the font's glyphs of that form, and Default with its default glyphs. Needs +/// craft-fonts' BIZ UDMincho, which has traditional, expert, JIS 78, 83 and 90, full-width and +/// half-width forms and no JIS 04 forms (skipped without it). A form on one character of a run +/// changes that character only. +#[test] +fn glyph_forms_draw_the_fonts_east_asian_forms() { + use vectorcraft_doc::GlyphForm; + const FAMILY: &str = "BIZ UDMincho"; + if db().face(FAMILY, "Regular").is_none_or(|f| f.family != FAMILY) { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let st = |form| CharStyle { size: 20.0, font_family: FAMILY.into(), glyph_form: form, ..CharStyle::default() }; + let glyphs = |text: &str, form| layout(db(), &TextObject::point(Point::ZERO, text, st(form))).glyphs; + let gids = |text: &str, form| glyphs(text, form).iter().map(|g| g.gid).collect::>(); + for (form, text) in + [(GlyphForm::Traditional, "亜"), (GlyphForm::Expert, "唖"), (GlyphForm::Jis78, "唖"), (GlyphForm::Jis83, "芦"), (GlyphForm::Jis90, "芦")] + { + assert_ne!(gids(text, form), gids(text, GlyphForm::Default), "{form:?} {text}"); + } + // The font has no JIS 04 forms, so the characters keep their glyphs. + assert_eq!(gids("亜唖芦", GlyphForm::Jis04), gids("亜唖芦", GlyphForm::Default)); + // Full-width Latin takes a whole em, half-width punctuation half of one. + let advance = |text: &str, form| glyphs(text, form).first().map_or(0.0, |g| g.advance); + assert!(advance("A", GlyphForm::Default) < 15.0, "{}", advance("A", GlyphForm::Default)); + assert!((advance("A", GlyphForm::FullWidth) - 20.0).abs() < 0.01, "{}", advance("A", GlyphForm::FullWidth)); + assert!((advance("、", GlyphForm::Default) - 20.0).abs() < 0.01, "{}", advance("、", GlyphForm::Default)); + assert!((advance("、", GlyphForm::HalfWidth) - 10.0).abs() < 0.01, "{}", advance("、", GlyphForm::HalfWidth)); + // Traditional Forms on the middle character only. + let mut t = TextObject::point(Point::ZERO, "亜亜亜", st(GlyphForm::Default)); + edit::style_range(&mut t.runs, 3, 6, |s| s.glyph_form = GlyphForm::Traditional); + let ids: Vec = layout(db(), &t).glyphs.iter().map(|g| g.gid).collect(); + let (plain, traditional) = (gids("亜", GlyphForm::Default), gids("亜", GlyphForm::Traditional)); + assert_eq!(ids, [plain[0], traditional[0], plain[0]]); +} diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index d003fbcbce..904bb26dcb 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -108,6 +108,20 @@ pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) shared_styles(app, |_, _, style, _| Some(f(style))) } +/// The fonts (family and style) of the character styles the panels act on ([`shared`]), each +/// once, in the order the runs come. +pub(crate) fn selected_fonts(app: &VectorcraftApp) -> Vec<(String, String)> { + let fonts = std::cell::RefCell::new(Vec::<(String, String)>::new()); + shared_styles(app, |_, _, style, _| { + let mut fonts = fonts.borrow_mut(); + if !fonts.iter().any(|(family, face)| *family == style.font_family && *face == style.font_style) { + fonts.push((style.font_family.clone(), style.font_style.clone())); + } + Some(()) + }); + fonts.into_inner() +} + /// Dimensional controls use document units and tolerate arithmetic roundoff when comparing /// compensated runs. Keep raw styles available to font/paragraph controls and style definitions, /// even if a collapsed affine prevents editing dimensions. diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 7c0a18d14f..4fbdc4ed9a 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -9,12 +9,14 @@ //! that glyph as one of its alternates in its word. A glyph no feature reaches whose name does //! not belong to another letter is applied as a glyph choice, which may not join. Other glyphs //! are drawn dimmed, and their tooltips say why they cannot be applied to the selection. +//! With Show East Asian Options, the panel menu sets the selected characters' East Asian glyph +//! form ([`glyph_form_items`]). use std::collections::HashMap; use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; -use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; +use vectorcraft_doc::{CharStyle, GlyphForm, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, PathEl}; use vectorcraft_text::FontDb; @@ -781,11 +783,115 @@ fn zoom_button(ui: &mut Ui, id: &str, label: &str, tip: &str, enabled: bool) -> resp.on_hover_text(tl!(tip)).clicked() && enabled } -pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { +/// The menu label of East Asian glyph form `form` ([`GlyphForm::ALL`]). +fn glyph_form_label(form: GlyphForm) -> &'static str { + match form { + GlyphForm::Traditional => "Traditional Forms", + GlyphForm::Expert => "Expert Forms", + GlyphForm::Jis04 => "JIS 04 Forms", + GlyphForm::Jis90 => "JIS 90 Forms", + GlyphForm::Jis78 => "JIS 78 Forms", + GlyphForm::Jis83 => "JIS 83 Forms", + GlyphForm::FullWidth => "Monospaced Full-Width Forms", + GlyphForm::HalfWidth => "Monospaced Half-Width Forms", + GlyphForm::ThirdWidth => "Monospaced Third-Width Forms", + GlyphForm::QuarterWidth => "Monospaced Quarter-Width Forms", + GlyphForm::Default => "Revert To Default Forms", + } +} + +/// The ids of the faces that draw the characters [`glyph_form_items`] acts on: the faces the +/// fonts of their styles name, and the faces layout draws them with, fallback faces included: +/// the Type tool's selected range (every frame of its story after Select All in threaded text, +/// the whole text object at a caret), else every frame of the selected stories. Laid out once for +/// each document revision, selection and set of fonts while the menu is open, overflow text +/// included. +fn selection_faces(app: &VectorcraftApp, ui: &Ui) -> Vec { + type Key = (u64, u64, u64, Option<(NodeId, usize, usize)>, Vec); + let editing = super::character::text_editing(app).map(|(id, a, b)| (id, a.min(b), a.max(b))); + let Some(state) = app.session.active() else { return vec![] }; + let story = super::character::text_story(app); + let stories = match editing { + Some(_) => story.clone().unwrap_or_default(), + None => super::character::selected_stories(app), + }; + let db = FontDb::global(); + let key: Key = (state.uid, state.revision, db.generation(), editing, stories); + let slot = ui.scope_id().with("gl-form-faces"); + if let Some((cached, ids)) = ui.ctx().data(|d| d.get_temp::<(Key, Vec)>(slot)) + && cached == key + { + return ids; + } + let mut ids: Vec = + super::character::selected_fonts(app).iter().filter_map(|(family, style)| db.face(family, style)).map(|f| f.id()).collect(); + let mut add = |t: &vectorcraft_doc::TextObject, range: Option<(usize, usize)>| { + // Laid out as point type, so that text overflowing its area or path counts too. + let mut all = t.clone(); + all.kind = vectorcraft_doc::TextKind::Point; + for g in vectorcraft_text::layout(db, &all).glyphs { + // A glyph counts when its characters overlap the range (a ligature may start before it). + if range.is_none_or(|(a, b)| g.byte < b && g.byte + g.len.max(1) > a) && !ids.contains(&g.font_id) { + ids.push(g.font_id); + } + } + }; + let text = |id: NodeId| match state.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t), + _ => None, + }; + match (editing, story) { + // After Select All in threaded text: every frame of the story. + (Some(_), Some(frames)) => frames.into_iter().filter_map(text).for_each(|t| add(t, None)), + (Some((id, a, b)), None) => { + if let Some(t) = text(id) { + // A caret applies the form to all the text of the object. + add(t, (a < b).then_some((a, b))); + } + } + (None, _) => super::character::selected_story_texts(app).into_iter().for_each(|t| add(t, None)), + } + ui.ctx().data_mut(|d| d.insert_temp(slot, (key, ids.clone()))); + ids +} + +/// The East Asian glyph forms of the panel menu, with Show East Asian Options: each draws the +/// selected characters (all the text of the selected type objects without a Type tool range) in +/// one form of their font, and is checked when they all have it; a form none of the faces that +/// draw the selected text has a feature for ([`selection_faces`]) is disabled. Revert To Default +/// Forms draws the font's default glyphs. +/// Public behavior: [Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) +/// (Replace Asian characters with a different glyph form). +fn glyph_form_items(app: &mut VectorcraftApp, ui: &mut Ui) { + let has = super::character::text_style(app).is_some(); + let db = FontDb::global(); + let faces: Vec<_> = selection_faces(app, ui).into_iter().filter_map(|id| db.face_by_id(id)).collect(); + let current = super::character::shared(app, |s| s.glyph_form); + for form in GlyphForm::ALL { + let enabled = match form.feature() { + Some(tag) => faces.iter().any(|f| f.has_substitution_feature(*tag)), + None => has, + }; + let checked = form != GlyphForm::Default && current == Some(form); + if form == GlyphForm::Default { + ui.separator(); + } + // Choosing the form every selected character already has changes nothing. + if menu_item(ui, tl!(glyph_form_label(form)), enabled, checked) && current != Some(form) { + super::character::format(app, json!({"glyphForm": form.id()})); + } + } +} + +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Use the Selected Text's Font"), true, false) { set_pstate::>(ui.ctx(), "gl-font", None); } ui.separator(); + if app.session.prefs.show_east_asian_options { + glyph_form_items(app, ui); + ui.separator(); + } for (i, (name, _, _)) in SUBSETS.iter().enumerate() { let cur: usize = pstate(ui.ctx(), "gl-subset"); if menu_item(ui, tl!(name), true, cur == i) { @@ -979,6 +1085,59 @@ mod tests { text_anchors(&shapes(ctx, app, vec![])).into_iter().map(|(t, _)| t).collect() } + /// With Show East Asian Options, the panel menu lists the East Asian glyph forms; choosing one + /// draws the Type tool's selected characters in it, checks it, and Revert To Default Forms + /// takes it away. A form the font has no feature for is disabled. The second half needs + /// craft-fonts' BIZ UDMincho, which has JIS 78 forms and no JIS 04 forms (skipped without it). + #[test] + fn east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form() { + let (mut app, id) = editing("亜唖"); + let ctx = egui::Context::default(); + let labels = |app: &mut VectorcraftApp| menu_texts(&ctx, app, vec![]).into_iter().map(|(t, _)| t).collect::>(); + let plain = |l: &String| l.trim_start_matches(['✓', ' ']).to_string(); + app.session.prefs.show_east_asian_options = false; + assert!(!labels(&mut app).iter().map(plain).any(|l| l == "JIS 78 Forms"), "hidden without Show East Asian Options"); + app.session.prefs.show_east_asian_options = true; + let shown: Vec = labels(&mut app).iter().map(plain).collect(); + for form in GlyphForm::ALL { + assert!(shown.iter().any(|l| l == glyph_form_label(form)), "{form:?} in {shown:?}"); + } + const FAMILY: &str = "BIZ UDMincho"; + if FontDb::global().face(FAMILY, "Regular").is_none_or(|f| f.family != FAMILY) { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + app.run("text.setRangeStyle", json!({"id": id, "font": FAMILY})).unwrap(); + let forms = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap_or(0))).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.glyph_form)).collect::>(), + _ => panic!("text"), + }; + select(&mut app, 3, 6); + choose(&ctx, &mut app, "JIS 78 Forms"); + assert_eq!(forms(&app), [("亜".to_string(), GlyphForm::Default), ("唖".to_string(), GlyphForm::Jis78)]); + assert!(labels(&mut app).iter().any(|l| l.starts_with('✓') && l.ends_with("JIS 78 Forms")), "checked"); + // A selection whose first font has no JIS 83 forms, and whose second one does, takes them. + let fallback = vectorcraft_text::FALLBACK_FAMILY; + if FontDb::global().face(fallback, "Regular").is_some_and(|f| !f.has_substitution_feature(*b"jp83")) { + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 3, "font": fallback})).unwrap(); + select(&mut app, 0, 6); + choose(&ctx, &mut app, "JIS 83 Forms"); + assert!(forms(&app).iter().all(|(_, f)| *f == GlyphForm::Jis83), "{:?}", forms(&app)); + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 3, "font": FAMILY})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "glyphForm": "default"})).unwrap(); + select(&mut app, 3, 6); + choose(&ctx, &mut app, "JIS 78 Forms"); + } + // Choosing it again changes nothing, and the font has no JIS 04 forms: that item is disabled. + let n = app.session.active().unwrap().history.undo.len(); + choose(&ctx, &mut app, "JIS 78 Forms"); + choose(&ctx, &mut app, "JIS 04 Forms"); + assert_eq!(app.session.active().unwrap().history.undo.len(), n); + assert_eq!(forms(&app)[1].1, GlyphForm::Jis78); + choose(&ctx, &mut app, "Revert To Default Forms"); + assert!(forms(&app).iter().all(|(_, f)| *f == GlyphForm::Default), "{:?}", forms(&app)); + } + /// Show › Alternates for Current Selection lists the selected character's alternates from the /// font of its text, whatever font the panel browses; a double-click draws the character with /// one. A character without alternates, no selected character and a missing font each show a diff --git a/docs/mcp.md b/docs/mcp.md index 2e43c14d0e..8eca254a8f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8965,6 +8965,27 @@ as outlines. {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"tsume":50}}} ``` +## East Asian glyph forms + +`text.setFormat` and `text.setRangeStyle {glyphForm}` set the East Asian glyph form the characters are drawn in +([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): `"traditional"`, +`"expert"`, `"jis04"`, `"jis90"`, `"jis78"`, `"jis83"`, `"fullWidth"`, `"halfWidth"`, `"thirdWidth"` or +`"quarterWidth"`, which draw them with the font's OpenType feature `trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, +`fwid`, `hwid`, `twid` or `qwid` ([feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), +or `"default"`, the font's default glyphs. A character holds one form at a time, so setting one replaces another. The +text keeps its characters, and a character the font has no glyph of that form for keeps its own. Runs that differ only +in their form are shaped as one piece of text. The form is saved only when it is not `"default"`, character styles +carry it, PDF draws the glyphs the canvas draws, and live SVG text writes it in `font-feature-settings`. Another value +is refused with `` `glyphForm` must be one of "traditional", … ``. One undo step, and a `text.setFormat` call that +gives the form the characters already have records none. With Show East Asian Options on, the Glyphs panel menu lists +the ten forms and Revert To Default Forms: a form is checked when every selected character has it, choosing it again +does nothing, and it is disabled when none of the faces that draw the selected text, fallback faces included, has a +feature for it. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"glyphForm":"jis78"}}} +``` + ## Composite Fonts A composite font sets each class of character in a font of its own diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6375f3aa3..0a06409298 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1428,6 +1428,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Paragraph panel's Mojikumi Set lists the five predefined sets the help page names (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku and YakumonoZenkaku), then Line-end Punctuation Half Width, and `text.setFormat {mojikumi}` takes each of them. YakumonoHankaku sets every bracket, comma and full stop half width and takes a quarter em off each side of a middle dot; GyomatsuYakumonoHankaku keeps punctuation full width inside a line, sets an opening bracket flush at a line's start and a closing mark half width at its end, and takes the quarter em after a middle dot off at a line's end; GyomatsuYakumonoZenkaku keeps punctuation full width and adds a quarter em between Japanese and Latin text; YakumonoZenkaku sets characters as None does. These widths are VectorCraft's own rules, from the help page's one-line description of each set. Both composers and vertical type measure each line as it is set, the sets save with the document and paragraph styles, and a `.ai` save reports the new sets as left out (`crates/text/src/tests/vertical.rs`, `crates/ui-egui/src/panels/paragraph/tests.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. + The Glyphs panel menu (with the East Asian options) offers Traditional, Expert, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); each draws the selected characters with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`), one form at a time, and a form none of the faces that draw the selected text has a feature for is disabled. `text.setFormat` and `text.setRangeStyle {glyphForm}` set it; the native file, character styles, the canvas, PDF and SVG (`font-feature-settings`) keep it (`tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs` tests). Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end. diff --git a/docs/type-parity.md b/docs/type-parity.md index 72b8bcfc9d..862e36ed3e 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -69,6 +69,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: East Asian Type units (Q, Ha) | done | Preferences › Units › East Asian Type (`unitsAsianType`, Points by default) offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`). The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Tate-chu-yoko Settings… (`ui.tateChuYokoSettings`, `{tcyUpDown, tcyLeftRight}` in points) moves a marked block up or down and right or left as one piece. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `tests_vertical::tate_chu_yoko_settings_move_a_marked_block_up_down_left_and_right`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on`, `typecmd::tate_chu_yoko_settings_are_character_attributes_saved_only_when_set`, `dialogs::tate_chu_yoko::tests` | | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | +| CJK: East Asian glyph forms | done | Glyphs panel menu › Traditional Forms, Expert Forms, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms, with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): the selected characters are drawn with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`; [feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), one form at a time (`text.setFormat` / `text.setRangeStyle {glyphForm}`); a form none of the faces that draw the selected text (fallback faces included) has a feature for is disabled, and Revert To Default Forms draws the default glyphs. Saved natively only when set, carried by character styles, drawn on the canvas and in PDF, and written to SVG as `font-feature-settings`. Character Style Options has no field for it. Evidence: `tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs::tests::east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form` | | CJK: warichu | done | Character panel menu › Warichu and Warichu Settings… (`text.setFormat` / `text.setRangeStyle {warichu, warichuLines, warichuGap, warichuScale, warichuAlign}`, `ui.warichuSettings`), with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): marked characters set at a percentage of their size and stacked on 1 to 6 lines inside their line, one above the other in horizontal type and side by side in vertical type, with a line gap and eight alignments; a block stays whole on one line. Saved natively, carried by character styles, drawn on the canvas and in every export (SVG, PDF and `.ai` write such type as outlines). Type on a path sets the characters at their own size. Evidence: `tests::warichu`, `dialogs::warichu::tests`, `typecmd::area_tests::warichu_is_a_character_attribute_set_saved_and_exported` | | CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per class; SVG and legacy `.ai` write it as outlines. Not yet: custom character sets, the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | | CJK: ruby | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`) | #633 | From 55615be12c45af749c077bb4b3a0e97654c05b4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:51:29 -0800 Subject: [PATCH 73/82] Export: an HP-GL plot of the art's bounds leaves out images in dynamic symbols A plot of the art's bounds now leaves placed images, objects at 0% opacity and unpainted paths out of the art of a dynamic symbol, as it does for other symbols. An object that an instance's fill, stroke or opacity makes the plot draw is measured for that instance only, and an instance whose symbol holds nothing else is left out, so a document with only such instances is refused. --- crates/engine/src/cmd/fileio/encode.rs | 223 ++++++++++++++++++--- crates/engine/src/cmd/fileio/tests_hpgl.rs | 84 ++++++++ docs/mcp.md | 5 +- 3 files changed, 281 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0bb18483cb..c823b8866f 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -2,13 +2,13 @@ //! (unknown keys are ignored, so one params object can carry the options of several formats). use std::borrow::Cow; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; -use vectorcraft_doc::{Artboard, Document, Node, NodeKind}; +use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride}; use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; @@ -450,31 +450,85 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option { /// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden /// ([`hidden_nodes`]) and the ones in the art of the symbols that instances show taken out /// ([`without_nodes`]: symbol art is measured with its hidden objects). An instance of a symbol -/// whose art is all picked or hidden is hidden too. The art of a dynamic symbol is measured whole: -/// an instance can paint its objects, and finds them by their places in the art. +/// whose art is all picked or hidden is hidden too. The drawn instances of a dynamic symbol that +/// make the same appearance changes to its art are measured on a copy of the symbol of their own, +/// in which each object is judged with those changes ([`as_drawn`]); the objects taken out of +/// a dynamic symbol's art leave empty groups in their places, by which instances find the objects +/// they change. fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option { - // The symbols instances show (the first of each name) that aren't dynamic. + // The symbols instances show (the first of each name). 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(); + let shown: Vec = doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).map(|(i, _)| i).collect(); + // The sets of appearance changes the drawn instances of each dynamic symbol make to its art; + // `None` for a symbol with more than `MAX_ART_COPIES`, which is measured whole. + let mut sets: HashMap<&str, Option>>> = + shown.iter().filter_map(|i| doc.symbols.get(*i)).filter(|s| s.dynamic).map(|s| (s.name.as_str(), Some(Vec::new()))).collect(); + if !sets.is_empty() { + let mut walked = HashSet::new(); + for l in &doc.layers { + drawn_changes(doc, l, left_out, true, &mut walked, &mut sets); + } + } + // Each set of changes gets a copy of its symbol, appended after the symbols, which the instances + // that make those changes show instead. + let variant = |symbol: &str, n: &Node| -> Option { + let k = sets.get(symbol)?.as_ref()?.iter().position(|c| *c == own_changes(n))?; + Some(format!("{symbol}\u{0}{k}")) + }; + let mut copies: Vec<(usize, &[&SymbolOverride])> = Vec::new(); + let remapped_doc = sets.values().flatten().any(|v| !v.is_empty()).then(|| { + let mut d = doc.clone(); + for l in &mut d.layers { + if let Some(n) = remapped(l, &variant) { + *l = n; + } + } + for s in &mut d.symbols { + if let Some(n) = remapped(&s.art, &variant) { + s.art = n; + } + } + for i in &shown { + let Some(s) = d.symbols.get(*i).cloned() else { continue }; + for (k, c) in sets.get(s.name.as_str()).into_iter().flatten().flatten().enumerate() { + copies.push((d.symbols.len(), c.as_slice())); + d.symbols.push(vectorcraft_doc::Symbol { name: format!("{}\u{0}{k}", s.name), ..s.clone() }); + } + } + d + }); + let src = remapped_doc.as_ref().unwrap_or(doc); + // The symbols to measure with the changes their instances make (`None` for a static symbol), + // without the dynamic symbols measured whole. + let entries: Vec<(usize, Option<&[&SymbolOverride]>)> = shown + .iter() + .filter_map(|i| src.symbols.get(*i).map(|s| (*i, s))) + .filter(|(_, s)| !s.dynamic || sets.get(s.name.as_str()).is_some_and(Option::is_some)) + .map(|(i, s)| (i, s.dynamic.then_some(&[][..]))) + .chain(copies.iter().map(|(i, c)| (*i, Some(*c)))) + .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)) { + while let Some(s) = entries.iter().find_map(|(i, c)| { + src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty, *c, &mut Vec::new())) + }) { empty.insert(&s.name); } let left_out = &|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n)); - let layers = hidden_nodes(&doc.layers, left_out); - let symbols: Vec<(usize, Arc)> = shown + let layers = hidden_nodes(&src.layers, left_out); + let symbols: Vec<(usize, Arc)> = entries .iter() - .filter(|(_, s)| !empty.contains(s.name.as_str())) - .filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art))) + .filter_map(|(i, c)| { + let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?; + without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art)) + }) .collect(); if layers.is_none() && symbols.is_empty() { - return vectorcraft_render::encode::art_bounds(doc); + return vectorcraft_render::encode::art_bounds(src); } - let mut d = doc.clone(); + let mut d = remapped_doc.unwrap_or_else(|| doc.clone()); if let Some(layers) = layers { d.layers = layers; } @@ -486,22 +540,126 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option Vec<&SymbolOverride> { + n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect() +} + +/// `n` with each instance at any depth that `variant` gives another symbol (from the instance's +/// symbol and the instance) made an instance of that symbol; `None` when none is. +fn remapped(n: &Arc, variant: &dyn Fn(&str, &Node) -> Option) -> Option> { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + let name = variant(symbol, n)?; + let mut m = (**n).clone(); + if let NodeKind::SymbolInstance { symbol, .. } = &mut m.kind { + *symbol = name; + } + return Some(Arc::new(m)); + } + let children = n.children()?; + let mut out: Option>> = None; + for (i, c) in children.iter().enumerate() { + if let Some(new) = remapped(c, variant) + && let Some(slot) = out.get_or_insert_with(|| children.clone()).get_mut(i) + { + *slot = new; + } + } + out.map(|c| { + let mut m = (**n).clone(); + if let Some(children) = m.children_mut() { + *children = c; + } + Arc::new(m) + }) +} + /// 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 { +/// of these unless it is hidden or picked. In the art of a dynamic symbol (`changed`, the changes +/// an instance makes to it; `n` is at place `at`), each object is judged with those changes +/// ([`as_drawn`]). +fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>, changed: Option<&[&SymbolOverride]>, at: &mut Vec) -> bool { + let m = as_drawn(n, changed, at); !n.visible - || left_out(n) - || !has_effects(n) + || left_out(&m) + || !has_effects(&m) && 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::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().enumerate().all(|(i, c)| { + at.push(i as u32); + let out = all_left_out(c, left_out, empty, changed, at); + at.pop(); + out + }), NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()), _ => false, } } +/// Add the sets of appearance changes that the instances in `n` and its subtree make to the art of +/// the symbols `sets` names, each set once, and those of the instances in the art of the symbols the +/// instances show (`walked`, the symbols already looked in). The instances that `left_out` picks or +/// that are in an object it picks are left out, and with `hidden` (in a layer) the hidden ones and +/// those in a hidden object. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`. +fn drawn_changes<'a>( + doc: &'a Document, + n: &'a Node, + left_out: &dyn Fn(&Node) -> bool, + hidden: bool, + walked: &mut HashSet<&'a str>, + sets: &mut HashMap<&'a str, Option>>>, +) { + if hidden && !n.visible || left_out(n) { + return; + } + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + let changes = own_changes(n); + if let Some(entry) = sets.get_mut(symbol.as_str()) + && !changes.is_empty() + { + let full = match entry { + Some(list) if !list.contains(&changes) && list.len() < MAX_ART_COPIES => { + list.push(changes); + false + } + Some(list) => !list.contains(&changes), + None => false, + }; + if full { + *entry = None; + } + } + if walked.insert(symbol.as_str()) + && let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) + { + drawn_changes(doc, &s.art, left_out, false, walked, sets); + } + } + for c in n.children().into_iter().flatten() { + drawn_changes(doc, c, left_out, hidden, walked, sets); + } +} + +/// The most copies of one dynamic symbol's art [`bounds_without`] measures, one for each set of +/// appearance changes its instances make; a symbol whose instances make more sets is measured whole. +const MAX_ART_COPIES: usize = 64; + +/// `n`, the object at place `at` of a symbol's art, with the appearance changes `changed` that an +/// instance makes to it applied in their order; `n` itself for the art of a symbol that is not +/// dynamic (`changed` is `None`) and when no change names `at`. +fn as_drawn<'a>(n: &'a Node, changed: Option<&[&SymbolOverride]>, at: &[u32]) -> Cow<'a, Node> { + let here: Vec = + changed.into_iter().flatten().filter(|o| o.path == at).map(|o| SymbolOverride { path: Vec::new(), ..(*o).clone() }).collect(); + if here.is_empty() { + return Cow::Borrowed(n); + } + let mut m = n.clone(); + vectorcraft_doc::symbol_overrides::apply(&mut m, &here); + Cow::Owned(m) +} + /// 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 { @@ -511,25 +669,32 @@ fn has_effects(n: &Node) -> bool { /// `n` without the objects that `left_out` picks in it, when it is a layer or a group, and in the /// layers and groups in it at any depth; `None` when it picks none of them. A clipping layer or /// group keeps its clipping path, its first object, and a Shaper group and a layer or group with -/// effects, fills or strokes of its own keep all they hold. -fn without_nodes(n: &Arc, left_out: &dyn Fn(&Node) -> bool) -> Option> { +/// effects, fills or strokes of its own keep all they hold. In the art of a dynamic symbol +/// (`changed`, the changes an instance makes to it, as for [`all_left_out`]; `n` is at place +/// `at`), each object is judged with those changes ([`as_drawn`]), and an object taken out leaves +/// an empty group in its place. +fn without_nodes(n: &Arc, left_out: &dyn Fn(&Node) -> bool, changed: Option<&[&SymbolOverride]>, at: &mut Vec) -> Option> { let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None }; - if has_effects(n) || n.extras().shaper.is_some() { + if has_effects(&as_drawn(n, changed, at)) || n.extras().shaper.is_some() { return None; } - let mut changed = false; + let mut taken = false; let kept: Vec> = children .iter() .enumerate() .filter_map(|(i, c)| { - if left_out(c) && !(*clip && i == 0) { - changed = true; - return None; - } - Some(without_nodes(c, left_out).inspect(|_| changed = true).unwrap_or_else(|| c.clone())) + at.push(i as u32); + let out = if left_out(&as_drawn(c, changed, at)) && !(*clip && i == 0) { + taken = true; + changed.map(|_| Arc::new(Node::group(c.id, Vec::new()))) + } else { + Some(without_nodes(c, left_out, changed, at).inspect(|_| taken = true).unwrap_or_else(|| c.clone())) + }; + at.pop(); + out }) .collect(); - changed.then(|| { + taken.then(|| { let mut new = (**n).clone(); if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind { *children = kept; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index a7fe16690e..82957468d8 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -254,6 +254,90 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() { } } +/// In the art of a dynamic symbol a placed image and a path that paints nothing are left out as +/// well, until an instance paints the path, and a document whose only other visible art is an +/// instance of a dynamic symbol of an image is refused. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_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 bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap(); + let same = |a: vectorcraft_geom::Rect, b: vectorcraft_geom::Rect| { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(a, b)| (a - b).abs() < 1e-6) + }; + 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(); + let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone(); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone(); + 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(); + let before = bounds(&s); + let inst = s.execute("symbol.new", &json!({"name": "Mixed", "ids": [image, square, bare], "type": "dynamic"})).unwrap()["id"].clone(); + let after = bounds(&s); + assert!(same(before, after), "{before:?} {after:?}"); + + // A change of the path's blending mode leaves it painting nothing; once the instance paints the + // path, the path is measured. + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + let x0 = |c: &std::sync::Arc| c.visual_bounds().map_or(f64::MIN, |b| b.x0); + let at = (0..art.children().unwrap().len()).max_by(|a, b| x0(&art.children().unwrap()[*a]).total_cmp(&x0(&art.children().unwrap()[*b]))).unwrap(); + s.execute("symbol.instance.setAppearance", &json!({"id": inst, "path": [at], "blend": "multiply"})).unwrap(); + let blended = bounds(&s); + assert!(same(before, blended), "{before:?} {blended:?}"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst, "path": [at], "stroke": "#000000"})).unwrap(); + let painted = bounds(&s); + assert!(painted.x1 > 500.0 && painted.y0 < -390.0, "{painted:?}"); + // A later change to the same object, as a file can hold, takes the paint off again. + { + let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap()); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let n = d.node_mut(id).unwrap(); + let mut overrides = n.extras().symbol_overrides.clone(); + overrides.push(vectorcraft_doc::SymbolOverride { path: vec![at as u32], opacity: Some(0.0), ..Default::default() }); + n.extras_mut().symbol_overrides = overrides.clone(); + let taken_off = bounds(&s); + assert!(same(before, taken_off), "{before:?} {taken_off:?}"); + overrides.pop(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.node_mut(id).unwrap().extras_mut().symbol_overrides = overrides; + } + // Another instance, far above, that doesn't paint the path is measured without it, whether or + // not the first one paints it. + let other = s.execute("symbol.place", &json!({"name": "Mixed", "x": 0, "y": -5000})).unwrap()["id"].clone(); + let with_paint = bounds(&s); + s.execute("symbol.instance.reset", &json!({"ids": [inst]})).unwrap(); + let without_paint = bounds(&s); + assert!((with_paint.y0 - without_paint.y0).abs() < 1e-6, "{with_paint:?} {without_paint:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let undone = bounds(&s); + assert!(same(undone, with_paint), "{undone:?} {with_paint:?}"); + s.execute("select.set", &json!({"ids": [other]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + + s.execute("select.set", &json!({"ids": [inst]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone(); + s.execute("symbol.new", &json!({"name": "Image", "ids": [image], "type": "dynamic"})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + + // An instance that brings a group at 0% opacity back to full opacity still draws nothing when + // the group holds only an image. + let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone(); + let group = s.execute("object.group", &json!({"ids": [image]})).unwrap()["id"].clone(); + s.execute("transparency.set", &json!({"ids": [group], "opacity": 0})).unwrap(); + let inst = s.execute("symbol.new", &json!({"name": "Clear", "ids": [group], "type": "dynamic"})).unwrap()["id"].clone(); + let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap()); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.node_mut(id).unwrap().extras_mut().symbol_overrides = + [vec![], vec![0]].into_iter().map(|path| vectorcraft_doc::SymbolOverride { path, opacity: Some(1.0), ..Default::default() }).collect(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); +} + /// 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] diff --git a/docs/mcp.md b/docs/mcp.md index cf41f3aaab..1e4591b906 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1091,8 +1091,9 @@ way. `document.exportPdf {useArtboards: false}` writes that one page as well, an 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. +Such objects in the art of a symbol are left out of its instances too, and an instance whose symbol holds nothing +else is left out. In the art of a dynamic symbol, an object that an instance's fill, stroke or opacity makes +the plot draw is measured. 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 25b451acecb86991d1b45b8cbf103cb5dc47db87 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:34:15 -0800 Subject: [PATCH 74/82] Effects: a clip group with its own paint exports the clip its clipping path's effects shape A clip group with a fill or stroke of its own and no geometry effect is baked through bake_pieces, which baked the clipping path like any other member instead of applying the geometry effects in it as the canvas clips by it. A compound clipping path whose member has a Transform effect with copies then baked into nested compound paths, and SVG, PDF and the other exports dropped the moved member and its copies from the clip. bake_pieces now applies with_clip_effects_applied to the clipping path first, as the plain clip group route already does. Clipping type keeps its own handling. --- crates/effects/src/bake.rs | 10 ++++--- crates/effects/src/tests/clipfx.rs | 42 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index d648dd9258..b0c50d5042 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -476,14 +476,18 @@ fn clip_group_with_type_clip(d: &mut Document, n: &Node) -> Option { /// `m`, art evaluated from an object, with its children baked and then given ids of their own (the /// pieces copied from the source keep the source's ids until then). The pieces are baked with the -/// ids the renderer draws them with: Random in a Transform effect takes its seed from the id. +/// ids the renderer draws them with: Random in a Transform effect takes its seed from the id. When +/// `m` clips, its clipping path, unless it is type, first takes the geometry effects in it as the +/// canvas clips by it ([`crate::clip::with_clip_effects_applied`]). fn bake_pieces(d: &mut Document, mut m: Node) -> Node { let mut seen = std::collections::HashSet::from([m.id]); + let clip = usize::from(m.clips() && !m.children().and_then(|ch| ch.first()).is_some_and(|c| matches!(c.kind, NodeKind::Text(_)))); let children = m.children().cloned().unwrap_or_default(); let children = children .into_iter() - .map(|c| { - let c = Arc::unwrap_or_clone(c); + .enumerate() + .map(|(i, c)| { + let c = (i < clip).then(|| crate::clip::with_clip_effects_applied(&c)).flatten().unwrap_or_else(|| Arc::unwrap_or_clone(c)); let mut c = one_deeper(|| bake_node(d, &c)).unwrap_or(c); fresh_ids(d, &mut c, &mut seen); Arc::new(c) diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs index 370f32fa81..bf63df61ab 100644 --- a/crates/effects/src/tests/clipfx.rs +++ b/crates/effects/src/tests/clipfx.rs @@ -172,6 +172,48 @@ fn the_clipping_paths_effects_apply_before_the_clip_groups() { assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "exported: {b:?} against {want:?}"); } +#[test] +fn a_clip_group_with_a_fill_or_stroke_of_its_own_clips_as_the_canvas_does() { + // A clip group with a fill or a stroke of its own and no effects: the export turns its paint + // into art and still clips by the outline the canvas clips by, Transform copies included. + let items = [ + vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::BLACK))), + vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 2.0)), + ]; + let copies = || fx("distort.transform", json!({"moveH": 60, "copies": 1})); + let roughen = fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false})); + // A ring: a compound path of two squares with effects `own`, its hole with effects `hole_fx`. + let ring = |hole_fx: Vec, own: Vec| { + let outer = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + let mut hole = Node::path(NodeId(4), shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)), Appearance::default()); + hole.appearance.effects = hole_fx; + let mut c = + Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: vectorcraft_geom::FillRule::EvenOdd }); + c.appearance.effects = own; + c + }; + let clips = [ + ("copies", clipping_path(2, vec![copies()])), + ("roughen", clipping_path(2, vec![roughen])), + ("a compound path's copies", ring(vec![], vec![copies()])), + ("a compound path member's copies", ring(vec![copies()], vec![])), + ]; + for item in &items { + for (what, clip) in &clips { + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let live = clip_outline(clip).unwrap(); + let mut group = clip_group(1, clip.clone()); + group.appearance.items.push(item.clone()); + d.insert(Some(layer), 0, group).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live, exported, "{what} with {item:?}: the canvas clips by the outline the export writes"); + } + } + let b = bounds(&clips[0].1); + assert!((b.x0 - 50.0).abs() < 1e-6 && (b.x1 - 210.0).abs() < 1e-6, "the clip covers both copies: {b:?}"); +} + #[test] fn nested_effects_apply_before_the_clip_groups() { // The clip group grows its clipping path by 10 (Offset Path). Inside the clipping path a From 2212af9653438b55104f37ed28f6762e578792f8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:38:31 -0800 Subject: [PATCH 75/82] Text: a COLR fill that paints nothing keeps the clip around the glyph after it A fill in a glyph's clip marked the clip as filled before the fill was added. When the fill painted nothing (a palette index past the palette, or a radial gradient that draws no circle), the next glyph painted in the same clip became the clip's only child, and closing the clip kept that glyph on its own, so it drew outside the clip. The clip is now marked filled only when the fill adds the clip's first child, and a fill that adds nothing leaves the mark as it was. --- crates/text/src/colr.rs | 15 ++++++++---- crates/text/src/tests/colr.rs | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs index 281ef8a65f..637dd9c243 100644 --- a/crates/text/src/colr.rs +++ b/crates/text/src/colr.rs @@ -508,12 +508,17 @@ impl ColorPainter for Tree<'_> { return; }; let Some(clip) = self.groups.get(at).and_then(|g| g.clip.clone()) else { return }; - if at + 1 == self.groups.len() - && let Some(g) = self.groups.last_mut() - { - g.flooded = g.children.is_empty(); - } + let innermost = at + 1 == self.groups.len(); + let before = self.groups.last().map_or(0, |g| g.children.len()); self.fill_path(&clip, &brush); + // Flooded only when this fill added the group's first child. A fill that paints nothing + // adds none and leaves the flag as it was, so the clip stays around what comes after it. + if innermost + && let Some(g) = self.groups.last_mut() + && g.children.len() > before + { + g.flooded = before == 0; + } } /// The glyph's outline filled, the brush moved by `brush_transform` (the glyph stays). diff --git a/crates/text/src/tests/colr.rs b/crates/text/src/tests/colr.rs index c6c4696f16..e59fb390c9 100644 --- a/crates/text/src/tests/colr.rs +++ b/crates/text/src/tests/colr.rs @@ -288,6 +288,49 @@ fn a_glyph_filled_with_one_colour_is_one_path() { assert_eq!(fill(only), Paint::solid(rgb(COLR_RED))); } +/// A fill that paints nothing (a palette index past the palette) followed by another glyph +/// inside the same glyph's clip keeps the clip: the second glyph is drawn clipped to the first, +/// not on its own. +#[test] +fn a_fill_that_paints_nothing_keeps_the_clip_around_the_glyph_after_it() { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, LayerList}; + use write_fonts::types::GlyphId16; + 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 mut colr = Colr::new(0, None, None, 0); + let paint = ColrPaint::glyph(ColrPaint::colr_layers(2, 0), gid('O')); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), paint)])).into(); + colr.layer_list = Some(LayerList::new(2, vec![solid(99), ColrPaint::glyph(solid(0), gid('I'))])).into(); + let font = crate::test_fonts::colr_font_with("Colrtest Empty Fill", &colr).unwrap(); + let root = art(&font, COLOUR_CHAR); + let [clipped] = children(&root) else { panic!("{:?}", children(&root).iter().map(|c| c.kind_label()).collect::>()) }; + assert!(matches!(clipped.kind, NodeKind::Group { clip: true, .. }), "the `O` still clips: {}", clipped.kind_label()); + let [clip, inner] = children(clipped) else { panic!("{:?}", children(clipped).iter().map(|c| c.kind_label()).collect::>()) }; + assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }), "{}", clip.kind_label()); + assert_eq!(fill(inner), Paint::solid(rgb(COLR_RED))); +} + +/// A fill that paints nothing after a colour flooding the glyph leaves it that glyph filled: +/// one path, as without the empty fill. +#[test] +fn a_fill_that_paints_nothing_after_a_flood_keeps_one_path() { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, LayerList}; + use write_fonts::types::GlyphId16; + 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 mut colr = Colr::new(0, None, None, 0); + let paint = ColrPaint::glyph(ColrPaint::colr_layers(2, 0), gid('O')); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), paint)])).into(); + colr.layer_list = Some(LayerList::new(2, vec![solid(0), solid(99)])).into(); + let font = crate::test_fonts::colr_font_with("Colrtest Flood Then Empty", &colr).unwrap(); + let root = art(&font, COLOUR_CHAR); + let [only] = children(&root) else { panic!("{:?}", children(&root).iter().map(|c| c.kind_label()).collect::>()) }; + assert!(matches!(only.kind, NodeKind::Path { .. }), "{}", only.kind_label()); + assert_eq!(fill(only), Paint::solid(rgb(COLR_RED))); +} + /// A sweep glyph in a font without clip boxes, its base glyph without an outline (the space's, /// as COLRv1 fonts make them): its image is as large as what it paints. #[test] From c61d27b825f0eb38ec7a9f74a674551f1167ddf1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:21:29 -0800 Subject: [PATCH 76/82] Metafile import: hidden WMF records leave the scale alone, and a far stroke keeps the strokes before it A WMF without a placeable box picks its scale from the map mode its drawing records are played in. Lines and shapes drawn under R2_NOP or R2_XORPEN, and PATBLT records the reader does not fill, no longer count, with the mix mode saved and restored with SAVEDC and RESTOREDC. An open stroke that reaches far outside the picture is left out before it joins the strokes waiting to be joined, so the strokes drawn before it are still added. --- crates/engine/tests/import_fuzz.rs | 10 ++++- crates/metafile/src/import/picture.rs | 61 +++++++++++++++++++++------ crates/metafile/src/tests/import.rs | 51 ++++++++++++++++++++++ 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index e22f8ac5cd..a768785abf 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2904,9 +2904,9 @@ fn arb_body() -> impl Strategy> { } /// WMF record functions VectorCraft reads, and one it doesn't. -const WMF_FUNCTIONS: [u16; 33] = [ +const WMF_FUNCTIONS: [u16; 35] = [ 0x0324, 0x0325, 0x0538, 0x041B, 0x0418, 0x061C, 0x0817, 0x081A, 0x0830, 0x0416, 0x0415, 0x02FA, 0x02FC, 0x02FB, 0x012D, 0x01F0, 0x0521, 0x0A32, - 0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0999, + 0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0103, 0x0104, 0x0999, ]; proptest! { @@ -2933,6 +2933,12 @@ proptest! { survive_metafile("WMF records", "x.wmf", &wmf_of(&records))?; } + #[test] + fn random_headerless_wmf_records_never_panic(records in prop::collection::vec((prop::sample::select(WMF_FUNCTIONS.to_vec()), arb_body()), 0..32)) { + // Without the placeable header, the map and mix modes the records set decide the scale. + survive_metafile("headerless WMF records", "x.wmf", &wmf_of(&records)[22..])?; + } + #[test] fn random_emf_plus_records_never_panic(records in prop::collection::vec((0x4001u16..0x403B, any::(), arb_body()), 0..32)) { // One comment of EMF+ records (type, flags, data) after an EmfPlusHeader. diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index 5385d94153..03a2e5628e 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -56,25 +56,39 @@ const PT_PER_PX: f64 = 72.0 / 96.0; /// Points a millimetre. const PT_PER_MM: f64 = 72.0 / 25.4; -/// WMF records that draw and that `craft_metafile` plays: lines, shapes, text, PATBLT and bitmaps. -const WMF_DRAWS: [u16; 16] = - [0x0213, 0x0324, 0x0325, 0x0418, 0x041B, 0x0521, 0x0538, 0x061C, 0x061D, 0x0817, 0x081A, 0x0830, 0x0940, 0x0A32, 0x0B41, 0x0F43]; +/// WMF records that draw lines and shapes, which `craft_metafile` plays only when the mix mode +/// lets them draw. +const WMF_SHAPES: [u16; 10] = [0x0213, 0x0324, 0x0325, 0x0418, 0x041B, 0x0538, 0x061C, 0x0817, 0x081A, 0x0830]; +/// WMF records that draw text and bitmaps, which `craft_metafile` plays whatever the mix mode. +const WMF_DRAWS: [u16; 5] = [0x0521, 0x0940, 0x0A32, 0x0B41, 0x0F43]; +/// SETROP2 mix modes under which `craft_metafile` draws no lines, shapes or brush fills: R2_XORPEN +/// and R2_NOP. +const WMF_MIX_SKIPS: [u16; 2] = [7, 11]; +/// The mix mode a WMF starts in (R2_COPYPEN). +const WMF_MIX_START: u16 = 13; +/// PATBLT raster operations `craft_metafile` draws: PATCOPY (with the brush, as the mix mode +/// allows), BLACKNESS and WHITENESS. +const PATCOPY: u32 = 0x00F0_0021; +const BLACKNESS: u32 = 0x0000_0042; +const WHITENESS: u32 = 0x00FF_0062; /// Does a WMF draw in a metric map mode (MM_LOMETRIC to MM_TWIPS)? `craft_metafile` plays those /// at 96 pixels an inch, whatever the window. The mode that more drawing records are played in -/// decides; a file that draws nothing goes by its last mode. `start` is where the standard header -/// begins. +/// decides, counting only records the reader draws (not lines and shapes under R2_NOP or +/// R2_XORPEN, nor a PATBLT it does not fill); a file that draws nothing goes by its last mode. +/// `start` is where the standard header begins. fn wmf_metric(bytes: &[u8], start: usize) -> bool { let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); let Some(words) = start.checked_add(2).and_then(u16_at) else { return false }; let mut at = start.saturating_add(usize::from(words) * 2); let mut mode = 1; + let mut mix = WMF_MIX_START; let metric = |mode: u16| (2..=6).contains(&mode); // Drawing records played in a metric mode, and in any other. let (mut in_metric, mut in_other) = (0usize, 0usize); - // The modes SAVEDC kept, which RESTOREDC brings back as the reader does. - let mut saved: Vec = Vec::new(); + // The map and mix modes SAVEDC kept, which RESTOREDC brings back as the reader does. + let mut saved: Vec<(u16, u16)> = Vec::new(); // The records as the reader walks them: it stops at the end record, a record shorter than its // header, one that runs past the file and a SAVEDC past its 1,024 levels. while let (Some(words), Some(function)) = (u32_at(at), u16_at(at.saturating_add(4))) { @@ -86,12 +100,14 @@ fn wmf_metric(bytes: &[u8], start: usize) -> bool { match function { // SETMAPMODE, when the record holds its mode. 0x0103 if size >= 8 => mode = u16_at(at + 6).unwrap_or(mode), + // SETROP2, when the record holds its mix mode. + 0x0104 if size >= 8 => mix = u16_at(at + 6).unwrap_or(mix), // SAVEDC. 0x001E => { if saved.len() >= SAVED_LEVELS { break; } - saved.push(mode); + saved.push((mode, mix)); } // RESTOREDC: a negative count pops that many levels, a positive one goes back to that // level. One that names no saved level changes nothing. @@ -99,20 +115,31 @@ fn wmf_metric(bytes: &[u8], start: usize) -> bool { let n = u16_at(at + 6).map_or(0, |v| i32::from(i16::from_le_bytes(v.to_le_bytes()))); let level = if n < 0 { saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) }; if let Some(level) = level - && let Some(m) = saved.get(level).copied() + && let Some((m, x)) = saved.get(level).copied() { mode = m; + mix = x; saved.truncate(level); } } - f if WMF_DRAWS.contains(&f) => { - if metric(mode) { + f => { + let draws = match f { + _ if WMF_DRAWS.contains(&f) => true, + _ if WMF_SHAPES.contains(&f) => !WMF_MIX_SKIPS.contains(&mix), + // PATBLT: its raster operation, when the record holds one. + 0x061D => match u32_at(at + 6).filter(|_| size >= 10) { + Some(PATCOPY) => !WMF_MIX_SKIPS.contains(&mix), + Some(rop) => rop == BLACKNESS || rop == WHITENESS, + None => false, + }, + _ => false, + }; + if draws && metric(mode) { in_metric += 1; - } else { + } else if draws { in_other += 1; } } - _ => {} } at = next; } @@ -401,6 +428,14 @@ impl Mapper { if let (None, Some((c, w))) = (fill, stroke) && !segs.iter().any(|s| matches!(s, Seg::Close)) { + // A stroke that reaches too far is left out here, before it joins the strokes + // waiting, so they are still added. + let half = f64::from(w) * self.k; + let half = if half > 0.0 && half.is_finite() { half / 2.0 } else { HAIRLINE / 2.0 }; + if !sane(bp.bounding_box().inflate(half, half)) { + self.flush(); + return self.warn(FAR_AWAY); + } let pen = Pen { color: c, width: w, line, even_odd, chain: chain.iter().map(|c| c.id).collect() }; let start = bp.elements().first().and_then(|e| match e { PathEl::MoveTo(p) => Some(*p), diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index a193997c4b..f5870c5f95 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -409,6 +409,57 @@ fn wmf_that_draws_in_a_metric_map_mode_and_restores_is_read_at_96_pixels_an_inch assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "{b:?}"); } +#[test] +fn wmf_records_the_mix_mode_keeps_from_drawing_do_not_set_its_scale() { + // A rectangle of 1440 units in MM_TEXT; then SAVEDC, MM_TWIPS, a mix mode and two records that + // draw nothing under it, and RESTOREDC. + let rect = (0x041B, le16(&[1440, 1440, 0, 0])); + let patblt = |rop: u32| (0x061D, [rop.to_le_bytes().to_vec(), le16(&[100, 100, 0, 0])].concat()); + let cases = [ + // R2_NOP and R2_XORPEN: rectangles draw nothing. + (11i16, [rect.clone(), rect.clone()]), + (7, [rect.clone(), rect.clone()]), + // R2_NOP: PATBLT with PATCOPY fills nothing. + (11, [patblt(0x00F0_0021), patblt(0x00F0_0021)]), + // R2_COPYPEN: PATBLT with DSTINVERT is not drawn. + (13, [patblt(0x0055_0009), patblt(0x0055_0009)]), + ]; + for (mix, hidden) in cases { + let mut records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), rect.clone()]; + records.extend([(0x001E, Vec::new()), (0x0103, le16(&[6])), (0x0104, le16(&[mix]))]); + records.extend(hidden); + records.push((0x0127, le16(&[-1]))); + let plain = hand_wmf(1440, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "mix {mix}: {p:?}"); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "mix {mix}: {b:?}"); + } + // R2_NOP, then SAVEDC, R2_COPYPEN and RESTOREDC bring R2_NOP back: the MM_TWIPS rectangle after + // them draws nothing. + let mut records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), rect.clone(), (0x0104, le16(&[11]))]; + records.extend([(0x001E, Vec::new()), (0x0104, le16(&[13])), (0x0127, le16(&[-1])), (0x0103, le16(&[6])), rect]); + let back = import(&hand_wmf(1440, 1440, &records)[22..]).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "{p:?}"); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "restored: {b:?}"); +} + +#[test] +fn a_stroke_that_reaches_too_far_leaves_the_strokes_before_it() { + // The black pen draws from (0, 500) to (100, 500), then on to a point far outside the picture. + let records = vec![(37, le(&[0x8000_0007u32 as i32])), (27, le(&[0, 500])), (54, le(&[100, 500])), (54, le(&[100_000_000, 500]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "{p:?}"); + let mm = 72.0 / 25.4; + let b = p[0].geometric_bounds().unwrap(); + assert!((b.x0).abs() < 0.01 && (b.x1 - mm).abs() < 0.01 && (b.y0 - 5.0 * mm).abs() < 0.01, "{b:?}"); + assert!(back.warnings.iter().any(|w| w.contains("far outside")), "{:?}", back.warnings); +} + #[test] fn damaged_files_fail_or_import_partly() { let good = emf(&sample()).bytes; From b12c78a7312a1d15ef2a568c002cbefae2a8e36f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:45:46 -0800 Subject: [PATCH 77/82] Graphs: Outline Stroke and a graph left with only labels keep the plot in place - Outline Stroke on a graph's strokes, including Object > Expand with Stroke on, adjusts the bounds the graph keeps, the same way Create Outlines on its labels does. Before, the stroke outlines changed the bounds of the art without type, and the next redraw stretched the plot by the width the outlines added. - A graph that keeps `placedArt` and has no art other than type left is no longer measured on the bounds of all its art. A label edit on such a graph moved and stretched the plot at the next redraw; the plot now stays where it is. - A redraw no longer measures a move or scale on art that has no width or no height now, such as a graph left with one axis line. Before, the plot collapsed to no width. - A clipping group in a graph that holds type is measured on its clip when the clip does not depend on type, so moving art outside the clip does not change the bounds. Blends, envelopes and repeats are measured whole, as they are drawn. - The doc crate's helper is named `graph_art_without_type` and sits with the file's other free functions. - docs/mcp.md lists Outline Stroke and states that moving an outlined label, a swatch, an axis or a tick mark on its own can move or scale the plot, and that a graph left with only labels, or with art that has no width or height, does not follow a move. - Tests: a scale of the graph made before outlining a label, a move of a graph opened from `.ai` after its label is restyled and moved, Outline Stroke after a move, a graph left with only its labels or with one line, and clipping groups holding type. --- crates/doc/src/graph.rs | 65 ++++++----- crates/engine/src/cmd/graph/edit.rs | 36 ++++-- crates/engine/src/cmd/graph/tests.rs | 147 ++++++++++++++++++++++++- crates/engine/src/cmd/pathops.rs | 3 + crates/eps/src/import/ai/read/graph.rs | 2 +- docs/mcp.md | 12 +- 6 files changed, 222 insertions(+), 43 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 0b726cd0c1..a74f6d0f18 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -527,7 +527,7 @@ pub struct GraphSpec { /// Bounds of the art when it was last generated (detects moves/scales of the graph group). #[serde(skip_serializing_if = "Option::is_none")] pub placed: Option, - /// Bounds of the art other than type when it was last generated ([`art_bounds`]). Moves and scales of the graph + /// Bounds of the art other than type when it was last generated ([`graph_art_without_type`]). Moves and scales of the graph /// group are measured on these when present; graphs saved without them are measured on [`Self::placed`]. #[serde(skip_serializing_if = "Option::is_none")] pub placed_art: Option, @@ -546,6 +546,43 @@ fn edge_to_edge_before_the_option() -> bool { true } +/// The geometric bounds of a graph's art `children` leaving out their type: the plot, axes, tick marks and legend +/// swatches without the labels. None when there is nothing else. [`GraphSpec::placed_art`] keeps these. +pub fn graph_art_without_type(children: &[std::sync::Arc]) -> Option { + children.iter().filter_map(|c| without_type(c).0).reduce(|a, b| a.union(b)) +} + +/// The geometric bounds of `n` leaving out the type in it, and whether its own geometric bounds depend on that type. A +/// group without such type gives its own bounds, and a clipping group whose clip does not depend on type gives the +/// bounds of its clip. Objects other than groups, layers and compound paths, blends, envelopes and repeats among them, +/// give their own bounds. +fn without_type(n: &crate::Node) -> (Option, bool) { + use crate::NodeKind::{Compound, Group, Layer, Text}; + if matches!(n.kind, Text(_)) { + return (None, true); + } + let children: &[std::sync::Arc] = match &n.kind { + Group { clip: true, children } | Layer { clip: true, children, .. } => children, + Group { .. } | Layer { .. } | Compound { .. } => n.editing_children(), + _ => return (n.geometric_bounds(), false), + }; + let mut bounds: Option = None; + let mut had_type = false; + let mut clip_has_type = false; + let hidden_layer_member = |c: &crate::Node| !c.visible && matches!(n.kind, Layer { clip: false, .. }); + for (i, c) in children.iter().enumerate().filter(|(_, c)| !hidden_layer_member(c)) { + let (b, t) = without_type(c); + had_type |= t; + clip_has_type |= i == 0 && t; + bounds = match (bounds, b) { + (Some(a), Some(b)) => Some(a.union(b)), + (a, b) => a.or(b), + }; + } + let clip = matches!(n.kind, Group { clip: true, .. } | Layer { clip: true, .. }); + if had_type && (!clip || clip_has_type) { (bounds, true) } else { (n.geometric_bounds(), false) } +} + impl GraphSpec { /// The type series `s` is drawn as: its own type when it combines with the graph's, else the graph's. pub fn series_kind(&self, s: usize) -> GraphKind { @@ -614,32 +651,6 @@ impl GraphSpec { } } -/// The geometric bounds of a graph's art `children` leaving out their type: the plot, axes, tick marks and legend -/// swatches without the labels. None when there is nothing else. [`GraphSpec::placed_art`] keeps these. -pub fn art_bounds(children: &[std::sync::Arc]) -> Option { - children.iter().filter_map(|c| art_bounds_of(c).0).reduce(|a, b| a.union(b)) -} - -/// The geometric bounds of `n` leaving out the type in it, and whether there was type to leave out. A group without type -/// gives its own bounds, so a clipping group gives the bounds of its clip. -fn art_bounds_of(n: &crate::Node) -> (Option, bool) { - if matches!(n.kind, crate::NodeKind::Text(_)) { - return (None, true); - } - let Some(children) = n.children() else { return (n.geometric_bounds(), false) }; - let mut bounds: Option = None; - let mut had_type = false; - for c in children { - let (b, t) = art_bounds_of(c); - had_type |= t; - bounds = match (bounds, b) { - (Some(a), Some(b)) => Some(a.union(b)), - (a, b) => a.or(b), - }; - } - if had_type { (bounds, true) } else { (n.geometric_bounds(), false) } -} - impl Default for GraphSpec { fn default() -> Self { Self { diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index e1c5c336b3..77ce7a7b2a 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -1,4 +1,4 @@ -use vectorcraft_doc::graph::art_bounds; +use vectorcraft_doc::graph::graph_art_without_type as art_bounds; use super::axis::{MAX_TICK_MARKS, count_given, one_line}; use super::data::apply_data; @@ -21,12 +21,16 @@ fn bound_param(p: &Value, key: &str, cmd: &str) -> Result>> { /// The bounds a redraw of graph `n` measures a move or scale on, as kept at the last generation and as they are now: /// those of its art without type ([`GraphSpec::placed_art`]), or those of all its art when the graph keeps no such -/// bounds with a width and a height, or has no art without type now. +/// bounds with a width and a height. `None` when the art measured has no width or no height now, or the graph keeps +/// bounds of its art without type and has no such art now. fn measured(n: &Node) -> Option<(Rect, Rect)> { let kept = n.extras().graph.as_deref()?; let c = n.children()?; - let art = kept.placed_art.filter(|p| p.width() > 1e-9 && p.height() > 1e-9).zip(art_bounds(c)); - art.or_else(|| kept.placed.zip(children_bounds(c))) + let solid = |r: &Rect| r.width() > 1e-9 && r.height() > 1e-9; + match kept.placed_art.filter(solid) { + Some(placed) => art_bounds(c).filter(solid).map(|now| (placed, now)), + None => kept.placed.zip(children_bounds(c).filter(solid)), + } } /// `r` carried by the change from bounds `from` to bounds `to`: each edge stays at the same place relative to them. @@ -39,14 +43,28 @@ fn carried(r: Rect, from: Rect, to: Rect) -> Option { Some(Rect::new(to.x0 + (r.x0 - from.x0) * sx, to.y0 + (r.y0 - from.y0) * sy, to.x0 + (r.x1 - from.x0) * sx, to.y0 + (r.y1 - from.y0) * sy)) } -/// The graphs holding objects `ids`, each with the bounds of its art without type and of all its art. Give them to -/// [`keep_placed`] after an edit that changes a graph's art without moving the graph. +/// The graphs holding objects `ids`, that are among them or that are inside them, each with the bounds of its art +/// without type and of all its art. Give them to [`keep_placed`] after an edit that changes a graph's art without +/// moving the graph. pub(crate) fn graphs_holding(d: &Document, ids: &[NodeId]) -> Vec<(NodeId, Option, Option)> { - let mut out: Vec<(NodeId, Option, Option)> = vec![]; + let is_graph = |n: &Node| n.extras().graph.is_some(); + let mut found: Vec = vec![]; for id in ids { let Some(chain) = d.ancestry(*id) else { continue }; - let holder = chain.iter().rev().skip(1).copied().find(|a| d.node(*a).is_some_and(|n| n.extras().graph.is_some())); - let Some(gid) = holder.filter(|g| !out.iter().any(|(o, ..)| o == g)) else { continue }; + found.extend(chain.iter().rev().copied().find(|a| d.node(*a).is_some_and(is_graph))); + if let Some(n) = d.node(*id) { + n.walk(&mut |c| { + if is_graph(c) { + found.push(c.id); + } + }); + } + } + let mut out: Vec<(NodeId, Option, Option)> = vec![]; + for gid in found { + if out.iter().any(|(o, ..)| *o == gid) { + continue; + } let children = d.node(gid).and_then(Node::children); out.push((gid, children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c)))); } diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c160a78833..7737477ae9 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5796,6 +5796,141 @@ fn outlining_a_graphs_labels_leaves_the_plot_in_place() { let moved = spec(&s, id).rect; assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 15.0)).abs() < 1e-6, "{moved:?}"); assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}"); + // A scale of the graph made before outlining is still followed. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.scale", &json!({"sx": 150, "sy": 50, "origin": [moved.x0, moved.y0]})).unwrap(); + s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q3").0]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let scaled = spec(&s, id).rect; + assert!((scaled.x0 - moved.x0).abs() < 1e-6 && (scaled.y0 - moved.y0).abs() < 1e-6, "{scaled:?}"); + assert!((scaled.width() - 1.5 * rect.width()).abs() < 1e-6 && (scaled.height() - 0.5 * rect.height()).abs() < 1e-6, "{scaled:?}"); +} + +/// Outline Stroke on a graph's strokes leaves the plot where it is when the graph is drawn again, and a move of the +/// graph made before it is still followed. +#[test] +fn outlining_a_graphs_strokes_leaves_the_plot_in_place() { + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 20, "dy": 10})).unwrap(); + s.execute("object.path.outlineStroke", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 20.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 10.0)).abs() < 1e-6, "{moved:?}"); + assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}"); +} + +/// A graph left with only its labels, or with its labels and one vertical line, leaves the plot where it is when it is +/// drawn again after a label edit. +#[test] +fn a_graph_left_with_only_its_labels_leaves_the_plot_in_place() { + for keep_a_line in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + let (mut art, mut line) = (vec![], None); + s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| { + if n.children().is_none() && !matches!(n.kind, NodeKind::Text(_)) { + let vertical = n.geometric_bounds().is_some_and(|b| b.width() < 1e-9 && b.height() > 1.0); + if keep_a_line && vertical && line.is_none() { + line = n.geometric_bounds(); + } else { + art.push(n.id.0); + } + } + }); + assert!(!art.is_empty() && line.is_some() == keep_a_line); + s.execute("select.set", &json!({"ids": art})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let left = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert_eq!(vectorcraft_doc::graph::graph_art_without_type(left.children().unwrap()), line, "{:#?}", left.children()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 20})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + assert_eq!(spec(&s, id).rect, rect, "keeping a line: {keep_a_line}"); + } +} + +/// A graph saved without the bounds of its art without type, left with one vertical line, keeps its plot's size when +/// it is drawn again. +#[test] +fn a_graph_saved_with_only_its_whole_bounds_and_left_with_a_line_keeps_its_plot() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + let mut old = spec(&s, id); + old.placed_art = None; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().extras_mut().graph = Some(Box::new(old)); + let (mut art, mut line) = (vec![], None); + s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| { + if n.children().is_none() { + let vertical = !matches!(n.kind, NodeKind::Text(_)) && n.geometric_bounds().is_some_and(|b| b.width() < 1e-9 && b.height() > 1.0); + if vertical && line.is_none() { + line = Some(n.id); + } else { + art.push(n.id.0); + } + } + }); + assert!(line.is_some()); + s.execute("select.set", &json!({"ids": art})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + assert_eq!(spec(&s, id).rect, rect); +} + +/// The bounds a graph measures moves on take a clipping group holding type at the bounds of its clip, so art outside the +/// clip does not change them; a clipping group whose clip is type is measured on its other art. +#[test] +fn a_clipping_group_holding_type_is_measured_on_its_clip() { + use std::sync::Arc; + use vectorcraft_doc::graph::graph_art_without_type as measure; + use vectorcraft_geom::Affine; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label: Arc = + group(&n, "Legend").children().unwrap().iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().clone(); + let mut paths = vec![]; + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) { + paths.push(c.clone()); + } + }); + let (clip, art) = (Arc::new(paths[0].clone()), paths[1].clone()); + let clipped = |dx: f64, first: Arc, second: Arc| { + let mut far = art.clone(); + far.transform(Affine::translate((dx, 0.0)), false); + let mut g = vectorcraft_doc::Node::group(NodeId(999_999), vec![first, second, Arc::new(far)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + measure(&[Arc::new(g)]) + }; + assert_eq!(clipped(500.0, clip.clone(), label.clone()), clip.geometric_bounds()); + assert_eq!(clipped(500.0, clip.clone(), label.clone()), clipped(900.0, clip.clone(), label.clone())); + // A clip that is type leaves the other art to measure. + assert_ne!(clipped(500.0, label.clone(), clip.clone()), clipped(900.0, label.clone(), clip.clone())); + // A clip that is a clipping group holding type in its clipped art is measured on its own clip. + let mut inner = vectorcraft_doc::Node::group(NodeId(999_998), vec![clip.clone(), label.clone()]); + if let NodeKind::Group { clip, .. } = &mut inner.kind { + *clip = true; + } + let inner = Arc::new(inner); + assert_eq!(clipped(500.0, inner.clone(), label.clone()), clip.geometric_bounds()); + assert_eq!(clipped(500.0, inner.clone(), label.clone()), clipped(900.0, inner.clone(), label.clone())); + // A blend holding type is measured whole, as it is drawn. + let blend = vectorcraft_doc::Node::new(NodeId(999_997), NodeKind::Blend { children: vec![clip.clone(), label.clone()], spec: Box::default() }); + assert_eq!(measure(&[Arc::new(blend.clone())]), blend.geometric_bounds()); } /// A graph whose kept bounds of its art without type have no width follows a move with the bounds of all of its art. @@ -7113,9 +7248,17 @@ fn a_graph_opened_from_ai_measures_moves_without_its_labels() { assert_eq!((graphs.len(), labels.len()), (1, 1)); let (id, label) = (graphs[0], labels[0]); let before = spec(&s, id); - assert!(before.placed_art.is_some()); s.execute("select.set", &json!({"ids": [label.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 30})).unwrap(); s.execute("object.move", &json!({"dx": 80, "dy": -40})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap(); - assert_eq!(spec(&s, id).rect, before.rect); + let rect = spec(&s, id).rect; + assert_eq!(rect, before.rect, "after restyling and moving a label"); + // A move of the whole opened graph is still followed. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 15, "dy": 25})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap(); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 15.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 25.0)).abs() < 1e-6, "{moved:?}"); + assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}"); } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 878b3743cf..b5267fa990 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -1188,6 +1188,8 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Outline Stroke", |d, sel| { let before = d.clone(); + // Outlining the strokes in a graph does not move or scale its plot at the next redraw. + let graphs = super::graph::graphs_holding(d, &roots); // A dimension selected with the art it measures keeps its own lines: it is out of the // document while the art is outlined. let lifted = super::dimension::lift(d, &roots); @@ -1207,6 +1209,7 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { if changed == 0 || (kept && d.layers == before.layers) { return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into())); } + super::graph::keep_placed(d, &graphs); sel.set(new_sel.iter().copied()); Ok(new_sel) })?; diff --git a/crates/eps/src/import/ai/read/graph.rs b/crates/eps/src/import/ai/read/graph.rs index 9fb60408c7..609713fe16 100644 --- a/crates/eps/src/import/ai/read/graph.rs +++ b/crates/eps/src/import/ai/read/graph.rs @@ -631,7 +631,7 @@ impl<'a> Reader<'a> { let Some(paints) = series_paints(art, &tags, spec.kind == GraphKind::Line) else { return false }; spec.series_paints = paints; spec.placed = Some(placed); - spec.placed_art = art.children().and_then(|c| vectorcraft_doc::graph::art_bounds(c)); + spec.placed_art = art.children().and_then(|c| vectorcraft_doc::graph::graph_art_without_type(c)); tag_series(art, &tags); art.extras_mut().graph = Some(Box::new(spec)); true diff --git a/docs/mcp.md b/docs/mcp.md index fc03d070b6..76d53693cb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -870,10 +870,14 @@ leaves room for a legend label's stored indents, spacing and leading. When a graph is drawn again, its plot moves and scales with any move or scale of the graph made since it was last drawn. The graph saves the bounds of its art other than type as `placedArt` (and the bounds of all of its art as -`placed`), and measures the move or scale on those. Changing the size, indents or spacing of the labels, moving a label -on its own and `type.createOutlines` on labels do not move or scale the plot. A graph opened from a `.ai` file saves -`placedArt` too. A graph saved without `placedArt`, or with a `placedArt` that has no width or height, is measured on -`placed`. +`placed`), and measures the move or scale on those. Changing the size or indents of the labels, moving a label on its +own, `type.createOutlines` on labels and `object.path.outlineStroke` on the graph's strokes do not move or scale the +plot. Outlined labels are no longer type: moving one of them on its own, like moving a swatch, an axis or a tick mark on +its own, can move or scale the plot at the next redraw. When all of a graph's art other than type has been deleted, or +the art measured has no width or no height, a redraw leaves the plot where it is and does not follow a move or scale of +the graph. A graph opened from a `.ai` file +saves `placedArt` too. A graph saved without `placedArt`, or with a `placedArt` that has no width or height, is +measured on `placed`. The `appearance.*` commands, `graphicStyle.apply` and `graphicStyle.redefine`, and the paint, stroke, gradient and transparency commands given an `item` (or the Appearance panel's active row) store a graph label's object rows for its From f1cffdd8c09340edb069d17f3da8575b29b9c1e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:46:56 -0800 Subject: [PATCH 78/82] Type: a warichu line holds the ascent and descent of its outer block lines A block's line took the tallest ascent and deepest descent of its characters, or the height of the stack's em boxes, whichever was larger. The top block line's characters reach above the stack by their ascent past the em box, and the bottom line's below it by their descent, so with a wide Line Gap the line was too short for them. The line's ascent and descent now also take in each character's reach from its own block line's baseline. --- crates/text/src/layout/warichu.rs | 11 ++++++++--- crates/text/src/tests/warichu.rs | 18 ++++++++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/text/src/layout/warichu.rs b/crates/text/src/layout/warichu.rs index dd816599c6..b68b69efd9 100644 --- a/crates/text/src/layout/warichu.rs +++ b/crates/text/src/layout/warichu.rs @@ -287,6 +287,10 @@ fn place_block(g: &mut [SGlyph], s: &WariStyle, (vertical, whole): (bool, bool)) let rtl = !vertical && g.first().is_some_and(|gl| gl.level.is_rtl()); let (inset, outset) = if rtl { (after, before) } else { (before, after) }; let length = inset + block + outset; + // How far the glyphs reach above and below the line's baseline, each from its block line's + // baseline. + let above = g.iter().zip(&places).map(|(gl, p)| gl.ascent - p.1).fold(0.0, f64::max); + let below = g.iter().zip(&places).map(|(gl, p)| gl.descent + p.1).fold(0.0, f64::max); let glyphs = g.len(); for (i, (gl, (along, across, advance))) in g.iter_mut().zip(places).enumerate() { let em = gl.face.units_per_em() * gl.sy / r; @@ -300,9 +304,10 @@ fn place_block(g: &mut [SGlyph], s: &WariStyle, (vertical, whole): (bool, bool)) if i + 1 < glyphs { gl.keeps_next = true; } - // The line keeps the height of the block's own size, and holds the whole stack. - gl.ascent = (ascent / r).max(-top); - gl.descent = (descent / r).max(bottom); + // The line keeps the height of the block's own size, and holds the whole stack with each + // block line's glyphs. + gl.ascent = (ascent / r).max(-top).max(above); + gl.descent = (descent / r).max(bottom).max(below); if s.auto_leading { gl.leading /= r; } diff --git a/crates/text/src/tests/warichu.rs b/crates/text/src/tests/warichu.rs index 6aee052cbd..cbefb86bb5 100644 --- a/crates/text/src/tests/warichu.rs +++ b/crates/text/src/tests/warichu.rs @@ -626,6 +626,24 @@ fn a_block_of_several_fonts_keeps_the_tallest_of_them() { assert!(first_short.0 >= plain[pair.0].0 - 1e-6 && first_short.1 >= deepest - 1e-6, "{first_short:?} {plain:?}"); } +/// The line holds each block line's characters at their own height: with a wide Line Gap, the +/// ascent of the top block line's characters and the descent of the bottom one's stay inside the +/// line. +#[test] +fn a_wide_line_gap_keeps_the_outer_block_lines_inside_the_line() { + let small = layout(db(), &point("x", style(10.0))); + let (ascent, descent) = (small.lines[0].ascent, small.lines[0].descent); + for lines in [2, 3] { + let l = layout(db(), &with_block("cdefgh", CharStyle { warichu_lines: lines, warichu_gap: 20.0, ..marked(20.0) })); + let line = &l.lines[0]; + let block = &l.glyphs[2..8]; + let top = block.iter().map(|g| g.origin.y).fold(f64::INFINITY, f64::min); + let bottom = block.iter().map(|g| g.origin.y).fold(f64::NEG_INFINITY, f64::max); + assert!(line.baseline - line.ascent <= top - ascent + 1e-6, "{lines}: {} {} {top} {ascent}", line.baseline, line.ascent); + assert!(line.baseline + line.descent >= bottom + descent - 1e-6, "{lines}: {} {} {bottom} {descent}", line.baseline, line.descent); + } +} + /// Warichu Settings on characters not marked Warichu leave their shaping alone: "fi" keeps its /// ligature when only the f has other settings. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index fc03d070b6..b37f162985 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8965,7 +8965,8 @@ Options). Warichu characters are set at `warichuScale` percent of their size (10 `warichuLines` lines (a whole number from 1 to 6, default 2) inside their line: one above the other in horizontal type and side by side across the column in vertical type, the first line above or to the right. `warichuGap` is the space between the lines in points (0 to 100, default 0, document points as `baselineShift`). The stack is centered across the -line on the center of the line's ideographic em box, and the line keeps the height of the characters' own size. +line on the center of the line's ideographic em box, and the line keeps the height of the characters' own size, or +more when the stack needs it: the line's ascent and descent take in the characters of the outer lines. A stretch of warichu characters is one block that takes the settings of its first character; a character with other settings, another size or the other kind of leading (Auto or a number) starts a new one, and a tab, a forced line break or a tate-chu-yoko character ends it. In vertical type a character marked `tateChuYoko` as well is set as tate-chu-yoko at its own size, and two or three digits From dfc4db7a9b0f78040e24b4aa82eb3d6bdde6658d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:47:36 -0800 Subject: [PATCH 79/82] Docs: Japanese messages are listed as partial, with the report's count The Japanese row in docs/localization-parity.md said every message is translated, and the Japanese catalog line in docs/target-app-parity.md said 38 messages were missing, one of them waiting on #7214, which has merged. On main the untranslated report lists 98 messages without a Japanese row, 77 of which every complete-message language lacks. Both lines now give the counted rows and the report's count, and name the open pull requests that translate part of them. --- 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 1fededbc4e..ca00fd7816 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -37,7 +37,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | 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,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) | +| Japanese | `ja` | 4,004 / 4,020 (99.6%) | partial (3,562 `@msg` and 49 `@msgplural` rows, counted 2026-11-14: the untranslated report lists 98 messages without a Japanese row, 77 of which every complete-message language lacks; open #7116 and #7211 translate 35 of them) | 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` | 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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0a1f7c81b8..6d3bf3b90f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -917,7 +917,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Polish catalog (`crates/ui-egui/src/i18n/pl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 46 Polish messages: 44 that open #7211, #7214, #7116, #7330 and #1931 translate, and 2 that no language has a row for. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - 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 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 Japanese catalog (`crates/ui-egui/src/i18n/ja.tsv`) has 3,562 `@msg` rows and 49 `@msgplural` rows (counted 2026-11-14). The untranslated report (`untranslated_report`) lists 98 status and error messages in the sources that have no Japanese row: 77 that every complete-message language lacks, and 21 more. Open #7211 translates 34 of them, among them the composite font errors, and open #7116 translates the justification order error. 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. - A status or error message whose braces are literal, such as a plug-in's `expected {"objects": [...]}` or a command's `quotes must be {double?, single?}`, is translated only when the whole message matches its row: the row writes those braces doubled and has no placeholder, so it no longer replaces a different message that starts with the same words, such as a JSON reason `expected value at line 1 column 1`, which stays in English. A row without placeholders is found at any message length (`plain_message_keys_double_their_braces`, `literal_brace_rows_match_only_their_message`). - 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. From a750c997be40fb14e1c7aa083c78fffc925f0e0e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:51:09 -0800 Subject: [PATCH 80/82] Scripts: a property name converted from an object stays counted while its getter or setter runs A computed name such as o[key], where key's toString returns a rope, is flattened into a copy that only native code held. A getter or setter that started a collection dropped that copy from the heap estimate, so the live heap could pass the limit by one name per nested access. Indexed reads, ++ and -- on an indexed property, and Object.defineProperty now keep the converted name reachable while the accessor runs. --- crates/js/src/builtins/object.rs | 9 +++++---- crates/js/src/interp.rs | 18 ++++++++++++++---- crates/js/src/text_tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/js/src/builtins/object.rs b/crates/js/src/builtins/object.rs index 969a9c6094..af41bb7782 100644 --- a/crates/js/src/builtins/object.rs +++ b/crates/js/src/builtins/object.rs @@ -166,10 +166,11 @@ fn create(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult JsResult { let o = require_object(interp, &arg(args, 0), "defineProperty")?; - let k = interp.to_key(&arg(args, 1))?; - let desc = to_desc(interp, &arg(args, 2))?; - let roots = desc_values(&desc); - interp.rooted(&roots, |interp| interp.define_own_property(o, k, &desc, true))?; + interp.with_key(&arg(args, 1), |interp, k| { + let desc = to_desc(interp, &arg(args, 2))?; + let roots = desc_values(&desc); + interp.rooted(&roots, |interp| interp.define_own_property(o, k, &desc, true)) + })?; Ok(Value::Object(o)) } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index edc5282fe5..aa4a4c5c78 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1779,8 +1779,7 @@ impl Interp { return self.replace(2, v); } self.check_coercible(&base, &[])?; - let name = self.to_key(&k)?; - let v = self.get_value(&base, &name)?; + let v = self.with_key(&k, |i, name| i.get_value(&base, &name))?; self.replace(2, v)?; if with_this { self.push(base) } else { Ok(()) } } @@ -1852,8 +1851,7 @@ impl Interp { return self.replace(2, Value::Number(if prefix { new } else { old })); } self.check_coercible(&base, &[])?; - let name = self.to_key(&k)?; - let r = self.inc_property(&base, name, prefix, dec)?; + let r = self.with_key(&k, |i, name| i.inc_property(&base, name, prefix, dec))?; self.replace(2, r) } @@ -2670,6 +2668,18 @@ impl Interp { } } + /// Runs `f` with the property name [`Self::to_key`] makes from `v`. A name converted from a + /// value that is not a string is kept reachable while `f` runs, so a collection that a getter + /// or setter starts still counts its units. + pub(crate) fn with_key(&mut self, v: &Value, f: impl FnOnce(&mut Self, Key) -> JsResult) -> JsResult { + let name = self.to_key(v)?; + if matches!(v, Value::String(_)) { + return f(self, name); + } + let root = Value::String(Str::from_rc(name.clone())); + self.rooted(&[root], |i| f(i, name)) + } + /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that /// copy's bytes are reserved and added to the heap first, without taking fuel. The rope stays /// rooted while the copy is reserved; everything else the caller still needs must be diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 01fc613cc3..62145b6117 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -434,6 +434,32 @@ fn a_call_keeps_its_operands_while_a_string_receiver_is_boxed() { assert_eq!(realm.run(src), Ok(JsValue::Number(1.0))); } +#[test] +fn a_computed_key_stays_counted_while_its_getter_or_setter_runs() { + // `o` has an accessor named by a flat string of 131,073 units. `key`'s toString returns a new + // rope with the same units, so using `key` as a name makes a second flat copy, which must stay + // counted while an accessor makes a third. The heap has room for one more copy, not two. + let build = "var s = 'x'; for (var i = 0; i < 17; i++) s = s + s; function f(c) { return s + c; } \ + function copy() { var g = f('g'); g[0]; return 1; } var o = {}, name = f('k'); name[0]; \ + Object.defineProperty(o, name, { get: copy, set: copy }); \ + var key = { toString: function () { return f('k'); } }; 0"; + let realm = || { + let mut realm = Realm::with_options(Options::default(), Limits::default()); + assert_eq!(realm.run(build), Ok(JsValue::Number(0.0))); + let interp = realm.interp(); + interp.collect_garbage(); + let copy = crate::heap::string_bytes(131_073); + interp.limits.heap_bytes = interp.heap.bytes + copy + copy / 2; + interp.reset_gc_threshold(); + realm + }; + assert_eq!(realm().run("o[name]"), Ok(JsValue::Number(1.0)), "the accessor's own copy fits"); + for read in ["o[key]", "o[key] = 1", "o[key]++", "Object.defineProperty({}, key, { get value() { return copy(); } })"] { + let result = realm().run(read); + assert!(matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)), "{read}: {result:?}"); + } +} + #[test] fn compiling_a_pattern_counts_against_the_heap() { let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; From aaae3fe37844964cb215ccb0ff5b09c49499ed05 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:43:04 -0800 Subject: [PATCH 81/82] Links: rows of images in stored brush art show their thumbnails The panel collected the objects for its nested rows from the document as it is between commands, where the art of the stored brushes is not set, so rows whose owner is a brush found no object and drew a blank thumbnail. When an id is not found otherwise, the panel now sets that art on a copy of the document, as links.list does, and collects the objects from it. A recheck of the files at the same revision keeps the objects collected before. --- crates/ui-egui/src/panels/links.rs | 63 +++++++++++++++++++----- crates/ui-egui/src/panels/tests_links.rs | 22 +++++++++ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index f9d9b524c9..2c54b843a8 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -92,28 +92,29 @@ fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option< let mut c: Cache = pstate(ctx, "links-cache"); let stale = c.key != key || now - c.at > RECHECK; if stale { + let same_doc = c.key == key; c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort, "dcsTransparency": o.dcs })).ok()?; (c.key, c.at) = (key, now); // One pass over the stored art for the rows outside the layer tree and the objects whose - // opacity masks hold them, not one per frame. - let mut want: std::collections::HashSet = c.list["links"] + // opacity masks hold them, not one per frame. An object in the layer tree is looked up by + // its index when drawn. + let doc = app.session.active().map(|st| &st.doc); + let want: std::collections::HashSet = c.list["links"] .as_array() .into_iter() .flatten() .filter(|r| nested_row(r)) .flat_map(|r| [r["id"].as_u64(), r["owner"]["maskOf"].as_u64()]) .flatten() + .filter(|id| doc.is_some_and(|d| d.node(NodeId(*id)).is_none())) .collect(); - c.nested.clear(); - if !want.is_empty() - && let Some(st) = app.session.active() - { - st.doc.visit_stored_arcs(|n, place| { - if !place.in_layers() && want.remove(&n.id.0) { - c.nested.push((n.id.0, std::sync::Arc::clone(n))); - } - }); - c.nested.sort_by_key(|(id, _)| *id); + // The same revision keeps the objects collected for it, so their thumbnails are not drawn + // again each time the files are looked at. + if !(same_doc && want.iter().all(|id| c.nested_node(*id).is_some())) { + c.nested = match doc { + Some(d) if !want.is_empty() => stored_objects(d, want), + _ => vec![], + }; } } let want = first.filter(|_| o.info); @@ -126,6 +127,44 @@ fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option< Some(c) } +/// The objects of `doc` outside the layer tree whose ids are in `want`, sorted by id. The art of +/// the stored brushes is set on `doc` only while a command uses it ([`Document::mounted_art`]), so +/// when an id is not found otherwise that art is set on a copy of `doc`, which gives its objects +/// the ids `links.list` reports for them. +/// +/// [`Document::mounted_art`]: vectorcraft_doc::Document::mounted_art +pub(super) fn stored_objects( + doc: &vectorcraft_doc::Document, + mut want: std::collections::HashSet, +) -> Vec<(u64, std::sync::Arc)> { + let mut out = vec![]; + collect_stored(doc, &mut want, &mut out); + if !want.is_empty() && doc.mounted_art.is_empty() { + let art = vectorcraft_brush::stored_art::StoredArt::read(doc); + if !art.is_empty() { + let mut d = doc.clone(); + art.mount(&mut d); + collect_stored(&d, &mut want, &mut out); + } + } + out.sort_by_key(|(id, _)| *id); + out +} + +/// Adds the objects of `d` outside the layer tree whose ids are in `want` to `out`, taking their +/// ids out of `want`. +fn collect_stored( + d: &vectorcraft_doc::Document, + want: &mut std::collections::HashSet, + out: &mut Vec<(u64, std::sync::Arc)>, +) { + d.visit_stored_arcs(|n, place| { + if !place.in_layers() && want.remove(&n.id.0) { + out.push((n.id.0, std::sync::Arc::clone(n))); + } + }); +} + /// The ids of the selected image objects and placed documents. fn selected_images(app: &VectorcraftApp) -> Vec { let Some(st) = app.session.active() else { return vec![] }; diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 6b773e9d3b..45c1fc3b04 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -230,6 +230,28 @@ fn a_mask_rows_tooltip_names_its_object_inside_a_symbol() { assert!(text.iter().any(|t| t.contains(&want)), "{want} in {text:?}"); } +/// The row of an image in a stored brush's art finds its object for the thumbnail, though that art +/// is set on the document only while a command uses it. +#[test] +fn a_row_in_a_stored_brushs_art_finds_its_object() { + let dir = Folder::new("brush-art"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let leaf = dir.file("leaf.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": leaf, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("brush.new", json!({"type": "scatter", "name": "Leaf", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({"ids": [id]})).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "leaf.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "brush", "{list}"); + let row = row["id"].as_u64().unwrap(); + let doc = &app.session.active().unwrap().doc; + assert!(doc.mounted_art.is_empty(), "the brush's art is not set on the document"); + let found = super::links::stored_objects(doc, [row].into()); + assert_eq!(found.iter().map(|(id, _)| *id).collect::>(), [row]); + assert!(matches!(found[0].1.kind, vectorcraft_doc::NodeKind::Image(_)), "{:?}", found[0].1.kind); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); From 8f8956f385b655ec54af018f2fbc92b6affefcc8 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:35:26 +0100 Subject: [PATCH 82/82] CI: FreeBSD builds one crate at a time (the nightly run is killed for memory) (#7636) * CI: FreeBSD builds with two jobs The nightly FreeBSD run has been killed for memory (exit 137) while compiling the workspace's test targets, on both architectures, every night since at least 9 November. Two rustc processes at a time fit in the VM's 12 GB. * CI: one FreeBSD build job at a time With two jobs the engine and ui-egui test builds were still killed when they compiled together. --- .github/workflows/freebsd.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 007fcfec09..c74a3e83ab 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -31,3 +31,8 @@ jobs: require-craft-fonts: true craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 run-package-check: true + # The test builds of this workspace's largest crates (engine, ui-egui) need more than the + # VM's 12 GB when two compile at once: the build was killed (exit 137) every night with + # four jobs and again with two. One rustc at a time; it still uses every core. + cargo-build-extra-args: "-j 1" + cargo-test-extra-args: "-j 1"