From 6160d0bd458cac86777c5bff197aaeccc4a66239 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:32:31 -0500 Subject: [PATCH 01/92] Snap to Glyph: glyph bounds give corners and turn with rotated type (#5112) --- crates/tools/src/guides/targets.rs | 29 +++++++++++++++--- crates/tools/src/guides/tests.rs | 48 ++++++++++++++++++++++++++++-- docs/mcp.md | 2 +- docs/target-app-parity.md | 5 ++-- docs/type-parity.md | 5 ++-- 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 6fed43337..709e1f53d 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -139,6 +139,23 @@ pub(super) fn object_boxes(doc: &Document, moving: &[NodeId]) -> Vec { out } +/// The corners of glyph `g`'s bounding box in text space, clockwise from its top left as drawn: +/// the box of its outline in its own frame (font units), through `g.xf`, so it turns, slants and +/// scales with the glyph. `bb` is the outline's box in text space, used as it is for a glyph bent +/// along a path or with a frame that can't be undone. +fn glyph_box(g: &vectorcraft_text::PositionedGlyph, bb: &Rect) -> [Point; 4] { + let corners = |r: Rect| [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + let det = g.xf.determinant(); + if g.bend.is_some() || !(det.is_finite() && det.abs() > 1e-12) { + return corners(*bb); + } + let local = (g.xf.inverse() * g.outline.clone()).bounding_box(); + if !(local.is_finite() && local.width() > 0.0 && local.height() > 0.0) { + return corners(*bb); + } + corners(local).map(|p| g.xf * p) +} + impl Targets { pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option) -> Self { Self::collect_inner(doc, exclude, None, visible, Gather::Bounds) @@ -337,10 +354,14 @@ impl Targets { } } if o.bounds { - let r = t.xf.transform_rect_bbox(bb); - for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { - self.xs.push((p.x, p, kind)); - self.ys.push((p.y, p, kind)); + // The glyph's box in its own frame (font units), placed as the glyph is: its + // corners are points to land on, even where the outline curves (the right of + // `B`), and its edges guides, which turn with rotated or slanted type. + let corners = glyph_box(g, &bb).map(to_doc); + for (i, c) in corners.iter().enumerate() { + self.points.push((*c, kind)); + let next = corners.get((i + 1) % 4).copied().unwrap_or(*c); + self.push_glyph_line(*c, next - *c, o.angular); } } } diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index e82618fb3..499410465 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -837,7 +837,7 @@ fn each_snap_to_glyph_option_takes_away_only_its_own_guides() { for (off, less) in [ (GlyphSnap { baseline: false, ..all }, [1, 0, 0, 0]), (GlyphSnap { x_height: false, ..all }, [1, 0, 0, 0]), - (GlyphSnap { bounds: false, ..all }, [4, 4, 0, 0]), + (GlyphSnap { bounds: false, ..all }, [2, 2, 0, 0]), (GlyphSnap { proximity: false, ..all }, [0, 2, 0, 0]), (GlyphSnap { anchors: false, ..all }, [0, 0, 0, anchors]), (GlyphSnap { angular: false, ..all }, [0, 0, 0, 0]), @@ -851,8 +851,8 @@ fn each_snap_to_glyph_option_takes_away_only_its_own_guides() { let p = Point::new(400.0, x_height + 2.0); assert!((Targets::for_drawing(&d, &[], None, Some(all)).snap_point(p, 4.0).0.y - x_height).abs() < 1e-9); assert_eq!(Targets::for_drawing(&d, &[], None, Some(GlyphSnap { x_height: false, ..all })).snap_point(p, 4.0).0.y, p.y); - // Rotated type: its baseline, x-height and width edges and its strokes run at an angle, all - // Angular Guides; its glyph bounds still run across and down the page. + // Rotated type: its baseline, x-height, width edges, glyph bounds and strokes run at an angle, + // all Angular Guides (the corners of its glyph bounds stay points to land on). let (d, _) = glyph_doc("H", 20f64.to_radians()); let (on, off) = (count(&d, all), count(&d, GlyphSnap { angular: false, ..all })); assert!(on[2] > 0, "{on:?}"); @@ -891,3 +891,45 @@ fn a_shift_constrained_point_slides_onto_a_rotated_baseline() { let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_from(crossing + Vec2::new(1.5, 0.3), Some(&from), 4.0); assert!(q.distance(crossing) < 1e-9, "{q:?} vs {crossing:?}"); } + +/// The corners of a glyph's bounds, from its outline in its own frame placed as the glyph is. +fn glyph_corners(t: &vectorcraft_doc::TextObject) -> [Point; 4] { + let lay = layout_text(FontDb::global(), t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + let r = (g.xf.inverse() * g.outline.clone()).bounding_box(); + [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|p| t.xf * g.xf * p) +} + +/// Snap to Glyph › Glyph Bounds (#5112): each corner of a glyph's bounds is a point a drawn point +/// lands on, also where its outline curves (the right of `B`, where no anchor is), and the corners +/// turn with rotated type. Glyph Bounds off, they are gone. +#[test] +fn a_drawn_point_lands_on_a_corner_of_a_glyphs_bounds() { + let style = vectorcraft_doc::CharStyle { size: 200.0, ..Default::default() }; + for angle in [0.0, 30f64.to_radians()] { + let (d, t) = glyph_doc_with("B", angle, style.clone()); + let corners = glyph_corners(&t); + for (i, c) in corners.into_iter().enumerate() { + let p = c + Vec2::new(2.0, -1.5); + let (q, ov) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0); + assert!(q.distance(c) < 1e-9, "angle {angle}: {q:?} vs corner {c:?}"); + // The left corners of `B` are anchors of its stem too; the right ones, on its curved + // side, come from its bounds alone. + if i == 0 || i == 3 { + continue; + } + assert!(ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "glyph")), "{ov:?}"); + let off = GlyphSnap { bounds: false, ..GlyphSnap::default() }; + let (q, _) = Targets::for_drawing(&d, &[], None, Some(off)).snap_point(p, 4.0); + assert!(q.distance(c) > 1e-3, "Glyph Bounds off: {q:?} still on {c:?}"); + } + // The right edge of the rotated glyph runs at its angle: a point beside it, far from the + // corners, lands on it. + let (tr, br) = (corners[1], corners[2]); + let dir = (br - tr) / (br - tr).hypot(); + let p = tr.midpoint(br) + Vec2::new(-dir.y, dir.x) * 1.5; + let (q, _) = + Targets::for_drawing(&d, &[], None, Some(GlyphSnap { anchors: false, proximity: false, ..GlyphSnap::default() })).snap_point(p, 4.0); + assert!((q - tr).cross(dir).abs() < 1e-6, "angle {angle}: {q:?} is off the right edge"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 7bce52793..91589c619 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3576,7 +3576,7 @@ Guides change where a snapped point lands: The Snap to Glyph options (the desktop app's Character panel menu › Show Snap to Glyph Options; View › Snap to Glyph is the desktop app's): `view.snapToGlyph.options {baseline?, xHeight?, bounds?, proximity?, angular?, anchors?}` (or `prefs.get`/`prefs.set` with key `glyphSnap`, a partial object) reads and sets which guides Snap to Glyph gives, all on -by default: the baseline and the x-height of a single line of type, the glyph bounds (each glyph's edges), the +by default: the baseline and the x-height of a single line of type, the glyph bounds (each glyph's edges and corners, turning with rotated type), the proximity guides at both edges of each character's width, the angular guides (along a glyph's slanted strokes, and every guide of rotated type, which runs at its angle) and the glyphs' anchor points. `{}` reads them; a key it doesn't know or a value that isn't a boolean is refused and changes nothing. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 544fea0df..4211e36a1 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-06 · **Change:** minor (Drop Shadow: Color's swatch opens the Color Picker, #5364; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Snap to Glyph's glyph bounds give corners and turn with rotated type, #5112; 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) ## Feature submission evidence @@ -892,7 +892,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A move pressed on a path's stroke, or within 6 px of its outline away from its anchors and center, is held by the nearest point of that path, with the Selection, Group Selection or Direct Selection tool, and a straight segment Direct Selection drags is held by its own nearest point. That point is what snaps, so the path lands exactly on a ruler guide (with "align") instead of the pressed spot landing there and the path a few pixels beside it, an offset that showed only zoomed far in (#3701). A press in the middle of a fill still snaps the pressed spot: the public [Align objects with guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) page has Snap to Point align the point under the pointer, which is placed exactly where the object should meet the guide. A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center. A user slice's edge or corner handle dragged with the Slice Selection tool snaps as the Slice tool's drag does. With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. Otherwise, with Smart Guides on, it lands on or lines up with the anchors, centers and edges of the art and the artboards, and with Smart Guides off and Snap to Point on, it snaps to the art's anchors and the ruler guides within the Snap to Point distance. Then a ruler guide within 5 px, shown and unlocked, moves the side or corner onto it. Shift still keeps the proportions, and Alt still resizes about the center. - View › Snap to Glyph: with Smart Guides on, a point drawn near a single line of live type lands on its glyphs' anchor points and lines up with its baseline, its x-height and the edges of each character's advance width (proximity guides), as well as the glyph bounds. In rotated type these guides run at the type's angle, and type of one glyph also has angular guides along its slanted straight segments. A paragraph shows glyph bounds only. + View › Snap to Glyph: with Smart Guides on, a point drawn near a single line of live type lands on its glyphs' anchor points and lines up with its baseline, its x-height and the edges of each character's advance width (proximity guides), as well as the glyph bounds: each glyph's box gives edge guides and four corners a point lands on, even where its outline curves (the right of `B`), and the box follows the glyph's size, scaling and rotation (#5112). In rotated type these guides run at the type's angle, and type of one glyph also has angular guides along its slanted straight segments. A paragraph shows glyph bounds only. Snap to Glyph options (#4232, [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)): the Character panel menu's Show Snap to Glyph Options (Hide Snap to Glyph Options) shows a Snap to Glyph section in the panel, with or without type selected, with a checkbox for each kind of guide: Baseline, x-height, Glyph Bounds, Proximity Guides (the edges of each character's width), Angular Guides and Anchor Point. Each off takes its guides away and leaves the others (`view.snapToGlyph.options`, the `glyphSnap` preference group, all on by default). Angular Guides covers a glyph's slanted strokes and every guide of rotated type, which runs at its angle; the boxes are dimmed while View › Snap to Glyph is off, and the section starts with a Snap to Glyph checkbox for it (VectorCraft choices; the page settles neither). Showing the section scrolls the panel to it, since it comes after every character attribute (#5273). Tested in `crates/tools/src/guides/tests.rs`, `crates/ui-egui/src/panels/character/tests.rs` and `crates/engine/src/tests/prefs.rs`. - **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/it/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls. - **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows. @@ -1703,6 +1703,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: their number fields have spinner arrows (#5367, #5368); evidence added; scores unchanged. | | 2026-11-06 | minor | Stroke Weight Up and Down (Cmd/Ctrl+Alt+. and ,) step each selected stroke's weight (#5797); evidence added; scores unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index c539baf2e..aea0d5300 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Snap to Glyph's glyph bounds give corners and turn with rotated type, #5112; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -52,7 +52,7 @@ against Illustrator's public documentation; **estimated** unless noted. | 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. | | Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | -| Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`): with Smart Guides on, new point type and drawing tools snap to live-text baselines and glyph bounds (`Targets::for_drawing`, `tools::text_tests::snap_to_glyph_lines_up_new_point_type_with_a_baseline`). Character panel menu › Show Snap to Glyph Options: Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides and Anchor Point each turn their guides on or off (`view.snapToGlyph.options`, all on by default; #4232), after a Snap to Glyph checkbox for View › Snap to Glyph; showing them scrolls the panel to them (#5273) | +| Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`): with Smart Guides on, new point type and drawing tools snap to live-text baselines and glyph bounds, the corners of each glyph's box included, turning with rotated type (#5112; `guides::tests::a_drawn_point_lands_on_a_corner_of_a_glyphs_bounds`, `Targets::for_drawing`, `tools::text_tests::snap_to_glyph_lines_up_new_point_type_with_a_baseline`). Character panel menu › Show Snap to Glyph Options: Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides and Anchor Point each turn their guides on or off (`view.snapToGlyph.options`, all on by default; #4232), after a Snap to Glyph checkbox for View › Snap to Glyph; showing them scrolls the panel to them (#5273) | | Thai, Lao, Khmer, Myanmar: shaping (marks, reordering), lines broken between words, word-by-word caret and double-click | done | words found by ICU4X's line segmenter: dictionaries on the desktop, LSTM models on the web | | Middle Eastern: bidirectional layout, Paragraph Direction | done | | | Middle Eastern and Indic: digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada, Malayalam) | done | `text.setFormat` / `text.setRangeStyle {digits}` (`"devanagari"`, `"bengali"`, `"gurmukhi"`, `"gujarati"`, `"odia"`, `"tamil"`, `"telugu"`, `"kannada"`, `"malayalam"` added, #4942); the text keeps the typed digits, the glyphs and SVG export follow the style; carried by character styles. Not yet: `.ai` export of the setting; Indic digits typed as such stay as typed under another system. Evidence: `text::tests::indic_digits_map_to_their_scripts_block`, `tests::bidi::indic_digits_draw_the_scripts_own_digits`, `typecmd::area_tests::indic_digits_round_trip_through_the_commands_and_the_file` | @@ -77,6 +77,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | | 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged | | 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. | | 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. | From 3f7f743ddb1b10a45b939659df37da51ddecc60c Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:42:43 -0500 Subject: [PATCH 02/92] 3D effects: Extrude & Bevel, Inflate and Rotate apply to groups A 3D effect on a group or layer builds a solid from every path, compound path and type in it, all turned about one shared axis, as the public docs describe for groups. Fixes #4506. --- crates/effects/src/solids.rs | 172 ++++++++++++++++++----- crates/effects/src/solids/tests.rs | 59 +++++++- crates/engine/src/cmd/effectcmd.rs | 5 +- crates/engine/src/cmd/effectcmd/tests.rs | 35 +++++ crates/render/src/fx/bounds.rs | 7 + crates/render/src/tests/objectfx.rs | 32 +++++ docs/effects-parity.md | 5 +- docs/three-d.md | 23 ++- 8 files changed, 297 insertions(+), 41 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 1165340bc..7025f8f6f 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -155,18 +155,87 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { crate::lighting::validate(p)?; let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); - let (path, rule) = - profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?; - let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect(); return Err(format!("bevel must be none or one of {}", names.join(", "))); } + if is_container(n) { + let shapes = members(n, label)?; + let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; + return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())); + } + let (path, rule) = profile(n).ok_or_else(|| { + format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") + })?; + let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; facets(id, &path, rule, b, p).map(|_| ()) } +/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint +/// it, and the opacity of the objects it is in (1 for the object with the effect itself). +struct Profile { + path: PathData, + rule: FillRule, + items: Vec, + opacity: f32, +} + +/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? +fn is_container(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) +} + +/// The bounds of every shape together: the centre a group's members turn about. +fn union_bounds(shapes: &[Profile]) -> Option { + shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c)) +} + +/// The most members a 3D effect on a group builds solids from. +const MAX_MEMBERS: usize = 4096; + +/// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path, +/// compound path and type in it, at any depth of its groups, each through its own geometry +/// effects and painted with its own fills and strokes. Like the effect on a group in the +/// reference app, they turn about one shared axis (the centre of their bounds together) rather +/// than each about its own. An error names what the group holds that can't become a solid. +fn members(n: &Node, label: &str) -> Result, String> { + fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec) -> Result<(), String> { + for c in n.children().into_iter().flatten() { + if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + continue; + } + let opacity = opacity * c.opacity; + if is_container(c) { + walk(c, opacity, label, out)?; + continue; + } + let Some((path, rule)) = profile(c) else { + let what = match &c.kind { + NodeKind::Group { clip: true, .. } => "clipping groups", + NodeKind::Image(_) => "images", + NodeKind::SymbolInstance { .. } => "symbol instances", + NodeKind::Path { clipping: true, .. } => "clipping paths", + _ => "objects other than paths, compound paths and type", + }; + return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first.")); + }; + if out.len() >= MAX_MEMBERS { + return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects")); + } + let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c)); + if path.bounds().is_some() { + out.push(Profile { path, rule, items: solid_paints(c), opacity }); + } + } + Ok(()) + } + let mut out = vec![]; + walk(n, 1.0, label, &mut out)?; + Ok(out) +} + /// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked /// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by /// [`validate_solid`]. @@ -226,10 +295,11 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group } -fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) { +/// `item`'s paint, its opacity times `member` (the opacity of the objects it is in) and its blend. +fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) { match item { - AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend), - AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend), + AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity * member, f.blend), + AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity * member, s.blend), } } @@ -249,46 +319,50 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, /// a surface one composite or masks it, their faces are drawn in one back-to-front order (where /// depths tie, as for the caps turned toward the viewer, in the order of the appearance); /// otherwise each solid is a surface of its own, in that order. -fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option>> { - let b = path.bounds()?; +fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { + let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let colored = view_options(&p).has_colored_lights(); if fx.id == ROTATE { - // Every part turns about the centre of the object's own bounds, so they stay together. + // Every part turns about the centre of the object's own bounds (a group's members about + // the centre of theirs), so they stay together. let mut out = vec![]; - for item in items { - let (paint, opacity, blend) = paint_of(item); - let (area, rule) = region(path, rule, item); - let f = facets(ROTATE, &area, rule, b, &p).ok()?; - out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored))); + for s in shapes { + 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()?; + out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored))); + } } return Some(out); } - let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); - let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items + // Each shape's top fill and its strokes, a group's members from the back up. + let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes .iter() - .enumerate() - .filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))) - .map(|(_, i)| { - let (area, rule) = region(path, rule, i); - (*i, area, rule) + .flat_map(|s| { + let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); + s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| { + let (area, rule) = region(&s.path, s.rule, i); + (i, s.opacity, area, rule) + }) }) .collect(); // The solids share the bounds of all their shapes: one center to turn, twist and taper about, // and Inflate's grid reaches over the strokes' outlines. - let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b); + let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b); let built = if fx.id == INFLATE { // One eye for every puff, as far away as the highest needs. - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect(); + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) } else { - parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect() + parts.iter().map(|(_, _, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect() }; let mut solids = vec![]; - for ((item, _, _), f) in parts.iter().zip(built) { + for ((item, opacity, _, _), f) in parts.iter().zip(built) { let item: &AppearanceItem = item; match f { - Ok(f) => solids.push((item, f)), + Ok(f) => solids.push((item, *opacity, f)), // A stroke's outline past the limits is left out; the fill's solid still draws. Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} // A loaded file or a shape edited past the limits keeps drawing its source path @@ -300,15 +374,15 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App if solids.is_empty() { return None; } - let opaque = solids.iter().all(|(item, _)| { - let (_, opacity, blend) = paint_of(item); + let opaque = solids.iter().all(|(item, member, _)| { + let (_, opacity, blend) = paint_of(item, *member); opacity >= 1.0 && blend == BlendMode::Normal }); let surfaces: Vec<(Vec, Node)> = solids .into_iter() - .map(|(item, f)| { + .map(|(item, member, f)| { let depths: Vec = f.iter().map(|f| f.depth).collect(); - let (paint, opacity, blend) = paint_of(item); + let (paint, opacity, blend) = paint_of(item, member); (depths, surface(n, f, &paint, opacity, blend, colored)) }) .collect(); @@ -338,19 +412,23 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping /// the object's id and transparency, the source path untouched. `None` without such an effect. +/// On a group or layer the effect builds a solid from each path, compound path and type in it, +/// all turned about one shared axis ([`members`]). pub fn solid_art(n: &Node) -> Option { if !has_solid(n) { return None; } + if is_container(n) { + return group_solid_art(n); + } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); let paints = solid_paints(n); - let painted: Vec<&AppearanceItem> = paints.iter().collect(); let mut children = vec![]; let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &path, rule, &painted)?); + children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -363,16 +441,16 @@ pub fn solid_art(n: &Node) -> Option { } else { let path = geometry(&path, &n.appearance.effects, &ctx); remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); - for item in &painted { + for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &p, rule, &[*item])? { + for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? { Arc::make_mut(&mut art).appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); children.push(art); } } else { - children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() }))); + children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); } } } @@ -382,5 +460,29 @@ pub fn solid_art(n: &Node) -> Option { Some(art) } +/// [`solid_art`] of group or layer `n`: its object-level 3D effect on its members together. The +/// geometry effects before it reshape the members, those after it bend the result as a whole; the +/// group's own fills and strokes are not drawn. `None` when the effect is only on one of the +/// group's own fills or strokes, or when its members can't become solids. +fn group_solid_art(n: &Node) -> Option { + let (index, fx) = solid(&n.appearance.effects)?; + let mut shapes = members(n, "").ok()?; + let before = n.appearance.effects.get(..index)?; + if before.iter().any(|e| e.visible && is_geometry(&e.id)) { + let b = union_bounds(&shapes)?; + let ctx = GeomContext::of(n); + for s in &mut shapes { + s.path = apply_geometry_with(before, &s.path, b, &ctx); + } + } + let children = art_for(n, fx, &shapes)?; + let remaining = + n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let mut art = n.clone(); + art.kind = NodeKind::Group { children, clip: false }; + art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); + Some(art) +} + #[cfg(test)] mod tests; diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 9dd72d4b1..a9997bb5d 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -462,8 +462,8 @@ fn parameters_are_validated_and_lengths_scale_with_the_object() { let n = square_node(); assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok()); assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be")); - let text = Node::group(NodeId(2), vec![]); - assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path")); + let empty = Node::group(NodeId(2), vec![]); + assert!(validate_solid(&empty, INFLATE, &json!({})).unwrap_err().contains("requires a group holding a shape")); for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] { assert!(crate::is_length(id, key, false), "{id} {key}"); } @@ -540,3 +540,58 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() { assert_eq!(clips, surfaces); assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count"); } + +/// `square_node` moved right by `dx`, with id `id` and fill `fill`. +fn square_at(id: u64, dx: f64, fill: Color) -> Node { + let mut n = square_node(); + n.id = NodeId(id); + n.transform(Affine::translate((dx, 0.0)), false); + n.appearance.items = Appearance::basic(Paint::solid(fill), Paint::None, 0.0).items; + n +} + +#[test] +fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() { + // Two squares side by side, 100 pt apart, in a group: 100–200 and 300–400 across. + let group = |id: &str, params: Value| { + let mut g = Node::group(NodeId(9), vec![Arc::new(square_at(1, 0.0, RED)), Arc::new(square_at(2, 200.0, BLUE))]); + g.appearance.effects.push(fx(id, params)); + g + }; + let turned = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}); + for id in SOLIDS { + let g = group(id, json!({})); + assert!(validate_solid(&g, id, &json!({})).is_ok(), "{id}"); + let art = solid_art(&g).unwrap_or_else(|| panic!("{id}")); + assert_eq!(art.id, NodeId(9), "{id}: the art is the group's"); + assert!(art.appearance.effects.iter().all(|e| !is_three_d(&e.id)), "{id}"); + // Both members draw, each in its own fill. + let mut colours = vec![]; + art.walk(&mut |c| { + if c.path_data().is_some() { + colours.push(fill_rgb(c)); + } + }); + assert!(colours.iter().any(|c| c[2] > c[0]) && colours.iter().any(|c| c[0] > c[2]), "{id}: red and blue faces"); + } + // Turned 60° about y, the members turn together about the group's centre (x = 250): the + // pair spans half its width; turned each about its own centre they would stay 100 pt apart. + let b = solid_art(&group(ROTATE, turned.clone())).unwrap().visual_bounds().unwrap(); + assert!((b.center().x - 250.0).abs() < 1e-3, "{b:?}"); + assert!((b.width() - 300.0 * 0.5).abs() < 1.0, "{b:?}"); + let mut alone = square_at(2, 200.0, BLUE); + alone.appearance.effects.push(fx(ROTATE, turned)); + let own = solid_art(&alone).unwrap().visual_bounds().unwrap(); + assert!((own.center().x - 350.0).abs() < 1e-3, "{own:?}"); + // Hidden members and guides are left out; a group holding what can't become a solid is + // refused with what to do. + let mut g = group(EXTRUDE, json!({})); + let mut clip = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]); + clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true }; + if let NodeKind::Group { children, .. } = &mut g.kind { + children.push(Arc::new(clip)); + } + let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("clipping groups"), "{err}"); + assert!(solid_art(&g).is_none(), "drawn as the group it is"); +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index f4c0073a3..a823ee161 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -754,7 +754,10 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // keeps it as its only member). Some((image, false)) => Some(rasterfx::replace_with_image(v, image)), image => { - let m = if container { + let m = if container && effects::has_solid(&v) { + // A group's or layer's 3D effect: the shaded faces of its members' solids. + effects::expand_leaf(d, &v, &mut stroke_art) + } else if container { // A group's or layer's own fills and strokes become art among its members. effects::evaluate_container(Some(&*d), &v).map(|mut m| { members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art); diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index ce8f51183..be1dd443c 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -79,6 +79,41 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() { } } +/// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis; +/// the members stay editable, and a group holding what can't become a solid is refused. +#[test] +fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { + for effect in effects::SOLIDS { + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.ellipse", &json!({"x": 250, "y": 120, "width": 80, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap(); + let art = effects::solid_art(&node(&s, g)).unwrap_or_else(|| panic!("{effect}")); + assert_eq!(node(&s, g).children().unwrap().len(), 2, "{effect}: the members stay as they were"); + // A member moved, the solid follows. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap(); + assert_ne!(effects::solid_art(&node(&s, g)).unwrap(), art, "{effect}"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "{effect}: both members exported as vector faces"); + s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); + assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded"); + } + // A clipping group in the group: refused, nothing changed. + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); + let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("clipping groups"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + /// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays /// type: editing its text reshapes the solid. #[test] diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 86f04ffa5..8bccf2c36 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -136,6 +136,13 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) }; match &n.kind { + // A group's or layer's 3D effect: the art of its members' solids. + NodeKind::Layer { .. } | NodeKind::Group { clip: false, .. } + if effects::has_solid(n) + && let Some(art) = effects::solid_art(n) => + { + drawn_bounds(symbols, &art) + } _ if effects::has_container_appearance(n) => { let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index edc044f0a..b6baab074 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -136,6 +136,38 @@ fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() { } } +/// #4506: a 3D effect on a group draws its members' solids on the canvas as the export does, and +/// the group's drawn bounds (what a change redraws) hold all of them. +#[test] +fn solid_effects_on_a_group_draw_as_exported_within_their_drawn_bounds() { + for (effect, params) in [ + ("threeD.extrude", json!({"depth": 30, "rotationX": -30, "rotationY": 40})), + ("threeD.inflate", json!({"depth": 10})), + ("threeD.rotate", json!({"rotationX": 0, "rotationY": 55, "rotationZ": 0})), + ] { + let square = |id: u64, x: f64, c: Color| { + Arc::new(Node::path( + NodeId(id), + shapes::rectangle(vectorcraft_geom::Rect::new(x, 35.0, x + 25.0, 60.0)), + Appearance::basic(Paint::solid(c), Paint::None, 0.0), + )) + }; + let mut g = Node::group(NodeId(0), vec![square(10, 15.0, Color::rgb(0.9, 0.2, 0.1)), square(11, 60.0, Color::rgb(0.1, 0.3, 0.9))]); + g.appearance.effects.push(fx(effect, params)); + let d = doc_with(Document::new(100.0, 100.0), g); + let live = render(&d); + let exported = render(&vectorcraft_effects::bake_document(&d).unwrap()); + assert_eq!(live.pixels, exported.pixels, "{effect}: canvas and baked/exported faces agree"); + let g = &d.layers[0].children().unwrap()[0]; + let art = vectorcraft_effects::solid_art(g).unwrap().visual_bounds().unwrap(); + let drawn = drawn_bounds(&d, g).unwrap(); + assert_eq!(drawn.union(art), drawn, "{effect}: the drawn bounds hold the art"); + let blues = live.pixels.as_chunks::<4>().0.iter().filter(|p| p[2] as u16 > p[0] as u16 * 2).count(); + let reds = live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[2] as u16 * 2).count(); + assert!(blues > 100 && reds > 100, "{effect}: both members drawn ({reds} red, {blues} blue px)"); + } +} + #[test] fn smooth_revolve_reduces_banding_without_changing_radial_resolution() { let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 20 L70 80").unwrap()); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index bfeb288af..d11b6bd80 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); previously: Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506)) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -179,7 +179,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Item | Ours | |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | -| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | +| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | @@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | | 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. | | 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. | | 2026-10-30 | minor | Pixelate › Color Halftone: Max Radius is in pixels of the Document Raster Effects resolution, 4–127 px, as the [filter effects reference](https://helpx.adobe.com/photoshop/using/filter-effects-reference.html) gives it, instead of points (#3260): fine screens are possible at print resolutions (4 px at 300 ppi ≈ 0.96 pt). The dialog and the Appearance panel show it in px whatever the document units; documents saved before (document versions 1 and 2) open with each radius converted, so their dots stay the same size, and a file saved for an older app version (native versions before today's) holds them in points again. Scores unchanged. Tests: `vectorcraft_effects` `color_halftone_sizes_its_dots_in_pixels_of_the_raster_effects_resolution` and `color_halftones_radius_is_in_raster_pixels_not_a_distance`, `vectorcraft_doc` `halftone_radii::tests`, `vectorcraft_format` `halftone_radii_of_older_documents_open_in_pixels`, `vectorcraft_engine` `a_preserve_editing_pdf_opens_with_its_halftone_radius_in_pixels`, `dialogs::tests::color_halftones_max_radius_stays_in_pixels_whatever_the_units`. | diff --git a/docs/three-d.md b/docs/three-d.md index 7b1a25c62..7cc339dff 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -70,6 +70,24 @@ The rest is our reading: grid stays flat. - A stroke whose outline takes more than 2,048 points is left out, and the fill's solid still draws. +## Groups + +Extrude & Bevel, Inflate and Rotate also apply to a group or layer. The public documentation says that applied to +several objects one by one each turns about its own axis, while applied to a group or layer all its objects turn +about a shared axis ([Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html), +said there of Revolve). So the effect on a group builds a solid from every visible path, compound path and type in +it, at any depth of its groups, each from its own fills and strokes as above ([Fills and strokes](#fills-and-strokes)), +and they turn, twist and taper together about the center of their bounds together; with Perspective one eye sees +them all. The members stay editable: moving or editing one reshapes the solids. Our reading: + +- Each member's own geometry effects shape it first; the group's geometry effects before the 3D effect reshape the + members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and + strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. +- When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn + on its own, the backmost member's first. +- Hidden members and guides are left out. A group holding an image, a symbol instance, a clipping group or another + live object is refused with a message saying what to release or expand; so is one with more than 4,096 shapes. + ## Rotate The flat art turned in 3D about the centre of its bounds. Every fill and stroke is turned with its own paint (a @@ -176,7 +194,9 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. drawn in pieces about as long as a wall slice is wide (fewer near the limit of 32,768 facets). The piece drawn on top can still lie a few points behind another nearby piece. - Extrude & Bevel and Rotate retain planar shading. Colored Inflate uses smooth vector lighting; white-light Inflate keeps its original polygon shading; bounded cell triangulation leaves a cell flat when it contains a complete hole or an unusually complex/degenerate contour. Its geometric silhouette is still a polygon approximation. -- Paths and compound paths only: convert type, images and groups to outlines first. One 3D effect per object. +- Paths, compound paths, type and groups or layers of them (see [Groups](#groups)); images, symbol instances, + clipping groups and other live objects have to be converted first. Revolve on a group is not built yet. One 3D + effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is counted on its own), Rotate (which only turns the outline) 65,536. @@ -189,5 +209,6 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| +| 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | | 2026-10-17 | Smooth colored Inflate lighting and surface batching; legacy white-light appearance and the 0.05 seam minimum preserved; shared light Color and multiple lights remain vector output. | From 7913c481aacc152245c21a7ac97720c3eb373f9a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 6 Nov 2026 23:16:42 +0700 Subject: [PATCH 03/92] Docs: keep the effects status entries --- docs/effects-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/effects-parity.md b/docs/effects-parity.md index d11b6bd80..2307add80 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506)) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it From b8ec5f4b237ebcb964fc12cbf32c29ed02eab62a Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:44:57 -0500 Subject: [PATCH 04/92] Type: Alt + numeric-keypad codes type their character on Windows; type.insert takes a code (#5108) --- apps/vectorcraft/src/app.rs | 3 + apps/vectorcraft/src/main.rs | 10 +- apps/vectorcraft/src/windows_alt_codes.rs | 225 ++++++++++++++++++++++ crates/engine/src/cmd/typemenu.rs | 34 +++- crates/engine/src/tests/menucmds.rs | 33 ++++ docs/type-parity.md | 4 +- 6 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 apps/vectorcraft/src/windows_alt_codes.rs diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 423fb53fc..5d9eb457f 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -51,6 +51,9 @@ impl eframe::App for App { // A pen contact with the barrel button held presses the secondary button (Windows Ink). #[cfg(windows)] windows_stylus::raw_input_hook(raw); + // The characters Alt + keypad codes typed (#5108). + #[cfg(windows)] + super::windows_alt_codes::raw_input_hook(raw); file_access::unconfined(|| self.app.raw_input_hook(ctx, raw)); // A mouse-up that never reached the window (#4326) is delivered once AppKit reports // that no mouse button is down. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 980d3826b..0078dd4b4 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -56,6 +56,7 @@ mod system_fonts; mod tablet; mod window; mod window_frame; +mod windows_alt_codes; #[cfg(windows)] mod windows_cores; mod windows_stylus; @@ -314,13 +315,18 @@ fn main() -> std::process::ExitCode { }; // With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end // is down; the message hook notes each pointer contact and whether it starts with the barrel - // button held, and writes the pen's end into the pen feed (`windows_stylus`). + // button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the + // characters Alt + keypad codes type (`windows_alt_codes`). #[cfg(windows)] let options = { use winit::platform::windows::EventLoopBuilderExtWindows as _; let mut options = options; options.event_loop_builder = Some(Box::new(|builder| { - builder.with_msg_hook(windows_stylus::msg_hook); + builder.with_msg_hook(|msg| { + // Alt + keypad codes (#5108), which winit drops; neither hook consumes a message. + windows_alt_codes::msg_hook(msg); + windows_stylus::msg_hook(msg) + }); })); options }; diff --git a/apps/vectorcraft/src/windows_alt_codes.rs b/apps/vectorcraft/src/windows_alt_codes.rs new file mode 100644 index 000000000..a4ef28dbb --- /dev/null +++ b/apps/vectorcraft/src/windows_alt_codes.rs @@ -0,0 +1,225 @@ +//! Alt + numeric-keypad codes on Windows (#5108): holding Alt, typing a decimal code on the +//! numeric keypad (NumLock on) and releasing Alt types that character (`Alt+0176` → `°`). +//! +//! Windows does the work: when Alt comes up after keypad digits, `TranslateMessage` posts a +//! `WM_CHAR` holding the character the code stands for (in the system's ANSI code page for a code +//! with a leading 0, else its OEM code page), the convention every Windows text field follows +//! (). winit +//! 0.30 reads a `WM_CHAR` only as part of the key press before it, so it drops this one, which +//! follows a key release, and egui never sees the text. winit's message hook ([`msg_hook`]) sees +//! the message before winit handles it: [`AltCodes`] notes keypad digits typed with Alt held, takes +//! the character that follows Alt's release, and [`raw_input_hook`] hands it to the frame as typed +//! text, so the Type tool (or a panel's text field) types it like any other character, in the +//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame, +//! so they don't run a shortcut bound to Alt and a digit. +//! +//! egui reports a keypad digit as the same key as the digit above the letters, so the other +//! platforms, whose systems have no such convention, can't tell them apart either. + +#![cfg_attr(not(windows), allow(dead_code))] + +use std::cell::RefCell; + +use egui::{Event, Key}; + +const WM_KEYDOWN: u32 = 0x0100; +const WM_KEYUP: u32 = 0x0101; +const WM_CHAR: u32 = 0x0102; +const WM_SYSKEYDOWN: u32 = 0x0104; +const WM_SYSKEYUP: u32 = 0x0105; +const WM_SYSCHAR: u32 = 0x0106; +/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys +/// are Insert, End, … and type no code. +const VK_NUMPAD0: usize = 0x60; +const VK_NUMPAD9: usize = 0x69; +/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt. +const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5]; + +thread_local! { + /// The UI thread's sequence: [`msg_hook`] and [`raw_input_hook`] both run on it. + static CODES: RefCell = RefCell::new(AltCodes::default()); +} + +/// Where an Alt + keypad code stands, from the keyboard messages of the thread's queue. +#[derive(Default)] +struct AltCodes { + /// Keypad digits typed while Alt has been held. + digits: usize, + /// Alt came up after keypad digits: the next character message is the code's character. + armed: bool, + /// A high surrogate waiting for its low half. + high: Option, + /// Characters the codes typed, for the next frame. + typed: String, + /// Keypad digit presses and releases with Alt held since the last frame, which egui reports + /// as Alt and a digit: taken out of the frame. + keypad_events: usize, +} + +impl AltCodes { + /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit). + fn message(&mut self, message: u32, wparam: usize) { + let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam); + match message { + // Windows sends a key pressed with Alt held as a system key. + WM_SYSKEYDOWN if keypad => { + self.digits += 1; + self.keypad_events += 1; + self.armed = false; + } + WM_SYSKEYUP if keypad => self.keypad_events += 1, + WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {} + // Any other key ends the sequence and keeps its own meaning. + WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(), + WM_KEYUP | WM_SYSKEYUP if ALT_KEYS.contains(&wparam) => { + self.armed = self.digits > 0; + self.digits = 0; + } + WM_CHAR | WM_SYSCHAR if self.armed => { + let unit = (wparam & 0xffff) as u16; + if (0xd800..0xdc00).contains(&unit) { + self.high = Some(unit); + return; + } + let units: Vec = self.high.take().into_iter().chain([unit]).collect(); + self.typed.extend(char::decode_utf16(units).filter_map(Result::ok).filter(|c| !c.is_control())); + self.armed = false; + } + _ => {} + } + } + + fn reset(&mut self) { + self.digits = 0; + self.armed = false; + self.high = None; + } + + /// Hand the frame `events` the characters typed since the last frame, as typed text, and take + /// out the keypad digits typed with Alt held. + fn drain(&mut self, events: &mut Vec) { + let mut keypad = std::mem::take(&mut self.keypad_events); + if keypad > 0 { + events.retain(|e| { + let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt); + if digit && keypad > 0 { + keypad -= 1; + return false; + } + true + }); + } + if !self.typed.is_empty() { + events.push(Event::Text(std::mem::take(&mut self.typed))); + } + } +} + +/// winit's message hook for the keyboard: notes Alt + keypad codes and the characters they type. +/// It never consumes the message. +#[cfg(windows)] +#[allow(unsafe_code)] +pub fn msg_hook(msg: *const std::ffi::c_void) { + use windows::Win32::UI::WindowsAndMessaging::MSG; + // SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this + // call; `as_ref` gives `None` for a null pointer. + let Some(msg) = (unsafe { msg.cast::().as_ref() }) else { + return; + }; + if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) { + CODES.with(|c| { + if let Ok(mut c) = c.try_borrow_mut() { + c.message(msg.message, msg.wParam.0); + } + }); + } +} + +/// Before egui reads the main window's frame: the characters Alt + keypad codes typed, as text. +pub fn raw_input_hook(raw: &mut egui::RawInput) { + CODES.with(|c| { + if let Ok(mut c) = c.try_borrow_mut() { + c.drain(&mut raw.events); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALT: usize = 0x12; + + /// Alt held, keypad digits `code`, Alt released, and the character Windows then posts. + fn alt_code(c: &mut AltCodes, code: &str, posted: u16) { + c.message(WM_SYSKEYDOWN, ALT); + for d in code.bytes() { + let vk = VK_NUMPAD0 + usize::from(d - b'0'); + c.message(WM_SYSKEYDOWN, vk); + c.message(WM_SYSKEYUP, vk); + } + c.message(WM_KEYUP, ALT); + c.message(WM_CHAR, usize::from(posted)); + } + + fn key(key: Key, alt: bool, pressed: bool) -> Event { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } + } + + #[test] + fn an_alt_code_types_its_character_when_alt_comes_up() { + let mut c = AltCodes::default(); + alt_code(&mut c, "0176", 0xB0); + alt_code(&mut c, "0151", 0x2014); + let mut events = vec![key(Key::Num0, true, true), key(Key::Num0, true, false), key(Key::Num1, false, true)]; + c.drain(&mut events); + // The keypad digits typed with Alt are gone (eight presses and eight releases, two here); + // the digit typed without Alt stays, and the characters follow. + assert_eq!(events, vec![key(Key::Num1, false, true), Event::Text("°—".into())]); + let mut events = vec![key(Key::Num0, true, true)]; + c.drain(&mut events); + assert_eq!(events.len(), 1, "counts don't carry over to a later frame"); + } + + #[test] + fn a_character_without_a_code_before_it_is_left_to_winit() { + let mut c = AltCodes::default(); + // A plain key press's character (winit types it), and Alt pressed and released alone. + c.message(WM_KEYDOWN, 0x41); + c.message(WM_CHAR, usize::from(b'a')); + c.message(WM_SYSKEYDOWN, ALT); + c.message(WM_KEYUP, ALT); + c.message(WM_CHAR, usize::from(b'b')); + let mut events = vec![]; + c.drain(&mut events); + assert!(events.is_empty(), "{events:?}"); + } + + #[test] + fn another_key_while_alt_is_held_cancels_the_code() { + let mut c = AltCodes::default(); + c.message(WM_SYSKEYDOWN, ALT); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); + // Alt+F: a shortcut, not a code. + c.message(WM_SYSKEYDOWN, 0x46); + c.message(WM_KEYUP, ALT); + c.message(WM_CHAR, 0x41); + let mut events = vec![]; + c.drain(&mut events); + assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}"); + } + + #[test] + fn a_code_above_the_basic_plane_arrives_as_two_halves() { + let mut c = AltCodes::default(); + c.message(WM_SYSKEYDOWN, ALT); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); + c.message(WM_KEYUP, ALT); + c.message(WM_CHAR, 0xD83D); + c.message(WM_CHAR, 0xDE00); + let mut events = vec![]; + c.drain(&mut events); + assert_eq!(events, vec![Event::Text("😀".into())]); + } +} diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 2056ddfb4..f5d36a7b2 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Insert Character", ["Type"], None, - "{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string, ids?|id?: object ids (default: the selection)} append to the selected text objects", + "{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string | code: Windows Alt+0 code (0–255 in Windows-1252, a Unicode code point above: 176 → °, 151 → —) | unicode: hex code point (\"00B0\" or \"U+00B0\"), ids?|id?: object ids (default: the selection)} append to the selected text objects", has_selection, insert_char ), @@ -580,12 +580,42 @@ pub(crate) fn special_char(name: &str) -> Option<&'static str> { }) } +/// The character a Windows Alt + keypad code with a leading 0 types (`Alt+0176` → `°`), as +/// `type.insert {code}` takes it: 128–159 in the Windows-1252 code page (`0151` → `—`), the +/// Unicode code point otherwise (`8364` → `€`). Control characters type nothing. +/// +pub(crate) fn alt_code_char(code: u64) -> Option { + const CP1252: [u16; 32] = [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C, + 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + let cp = match code { + 128..=159 => u32::from(*CP1252.get(usize::try_from(code - 128).ok()?)?), + _ => u32::try_from(code).ok()?, + }; + char::from_u32(cp).filter(|c| !c.is_control() || *c == '\t') +} + +/// The character `type.insert {unicode}` names: its code point in hex, as `00B0`, `U+00B0` or +/// `0x00B0`. +fn unicode_char(u: &str) -> Option { + let u = u.trim(); + let hex = u.strip_prefix("U+").or_else(|| u.strip_prefix("u+")).or_else(|| u.strip_prefix("0x")).unwrap_or(u); + char::from_u32(u32::from_str_radix(hex, 16).ok()?).filter(|c| !c.is_control() || *c == '\t') +} + fn insert_char(s: &mut Session, p: &Value) -> Result { const C: &str = "type.insert"; let text = match (str_param(p, "char"), str_param(p, "text")) { (Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(), (None, Some(t)) => t.to_string(), - _ => return Err(bad(C, "give `char` or `text`")), + _ => match (p.get("code"), str_param(p, "unicode")) { + (Some(code), _) => alt_code_char(code.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?) + .ok_or_else(|| bad(C, format!("no character has code {code}")))? + .to_string(), + (None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(), + _ => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")), + }, }; let ids = texts(s, p, C)?; // When `text` is empty and every object ends with a run of text, the document and its undo diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index ff7d362f3..173303e9d 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3462,6 +3462,39 @@ fn placeholder_and_insert_characters() { assert!(s.execute("type.insert", &json!({"char": "nope"})).is_err()); } +/// `type.insert` by code (#5108): `code` as a Windows Alt+0 code types (Windows-1252 for +/// 128–159, the code point otherwise), `unicode` as a hex code point. +#[test] +fn insert_a_character_by_its_code() { + let mut s = session(); + let t = text(&mut s, ""); + sel(&mut s, &[t]); + for p in [ + json!({"code": 176}), + json!({"code": 177}), + json!({"code": 151}), + json!({"code": 215}), + json!({"code": 8364}), + json!({"unicode": "00B0"}), + json!({"unicode": "U+2014"}), + ] { + s.execute("type.insert", &p).unwrap(); + } + assert_eq!(plain(&s, t), "°±—×€°—"); + for p in [ + json!({"code": 129}), + json!({"code": 7}), + json!({"code": -1}), + json!({"code": 1_114_112}), + json!({"unicode": "D800"}), + json!({"unicode": "zz"}), + json!({}), + ] { + assert!(s.execute("type.insert", &p).is_err(), "{p}"); + } + assert_eq!(plain(&s, t), "°±—×€°—"); +} + #[test] fn area_placeholder_fills_frame() { let mut s = session(); diff --git a/docs/type-parity.md b/docs/type-parity.md index aea0d5300..e5f51dd9d 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Snap to Glyph's glyph bounds give corners and turn with rotated type, #5112; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Alt + numeric-keypad codes type their character on Windows, and `type.insert` takes a code, #5108; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -49,6 +49,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | +| 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 (`windows_alt_codes` tests; not yet tried on a Windows machine). `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. | | Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | @@ -77,6 +78,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | | 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged | | 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. | From 42fe0bead320e5963575ccd9838b9132e19109d2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 04:44:51 -0800 Subject: [PATCH 05/92] Appearance and Eyedropper: reading an object's paint does not measure its bounds The Eyedropper, the Live Paint check for paint to sample, the Appearance panel's Characters chips and the colors the Libraries panel adds from the selection found the paint behind the Fill or Stroke proxy through Node::proxy_paint, which measures the object's geometric bounds. They now read it with Node::proxy_paint_only. - Eyedropper: the test for an empty fill reads the paint without measuring. A sample measures the object only for a gradient or a pattern, whose color depends on the point; a solid color gives its color at once. - Live Paint: the check for paint to sample reads the paint without measuring. - Appearance panel: the Characters row's two chips read the paint without measuring. - Libraries: adding a color or a pattern from the selection reads the paint without measuring; a gradient is still measured for its placement. The paints and colors are the same as before. On an open path of 200,000 anchors, the Live Paint check and the Eyedropper's sample for a point inside it take 0.004 ms together instead of 26.0 ms in a release build. Docs: docs/target-app-parity.md. --- crates/engine/src/cmd/library.rs | 5 + crates/engine/src/tests/library.rs | 60 +++++++++++ crates/tools/src/xform/eyedropper.rs | 130 ++++++++++++++++++++++-- crates/ui-egui/src/panels/appearance.rs | 2 +- docs/target-app-parity.md | 1 + 5 files changed, 191 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index c85fe7e37..b4f570582 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1036,6 +1036,11 @@ fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result { None => st.selection.objects.clone(), }; let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| { + // Only a gradient needs the object measured for its placement. + let paint = n.proxy_paint_only(stroke, None)?; + if !matches!(paint, Paint::Gradient(_)) { + return Some(paint.clone()); + } let (paint, to_doc, bounds) = n.proxy_paint(stroke, None)?; // Type's characters paint in its own space: a gradient there takes its placement in the // document, so that it keeps the direction it shows. diff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs index d232b2676..15a8da810 100644 --- a/crates/engine/src/tests/library.rs +++ b/crates/engine/src/tests/library.rs @@ -230,6 +230,66 @@ fn a_gradient_goes_into_a_library_and_paints_another_object() { assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Nope"})).is_err()); } +/// Compound paths, groups and colorized objects add the paint their Fill proxy shows: a color by +/// its hex, a gradient with its stops. +#[test] +fn colors_and_gradients_come_from_compound_paths_groups_and_colorized_objects() { + use std::sync::Arc; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Node, NodeKind}; + let mut s = session(); + let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string(); + let solid = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); + let graded = run(&mut s, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone(); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]); + run(&mut s, "paint.setFill", json!({"color": "#123456", "ids": [solid]})); + run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [graded]})); + let sources = [NodeId(solid.as_u64().unwrap()), NodeId(graded.as_u64().unwrap())]; + // For each source: a compound path and a group painted as it is, and a gray rectangle + // colorized with its fill. + let mut made: Vec<(NodeId, bool)> = vec![]; + s.edit("Make", |d, _| { + let l = d.layers[0].id; + for (k, src) in sources.into_iter().enumerate() { + let source = d.node(src).unwrap().clone(); + let fill = source.appearance.fill().unwrap().paint.clone(); + let gradient = matches!(fill, Paint::Gradient(_)); + let member = |d: &mut vectorcraft_doc::Document| { + let mut m = source.clone(); + (m.id, m.appearance) = (d.alloc_id(), Default::default()); + Arc::new(m) + }; + let (a, b) = (member(d), member(d)); + let mut compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![a], rule: Default::default() }); + let mut group = Node::group(d.alloc_id(), vec![b]); + compound.appearance = source.appearance.clone(); + group.appearance = source.appearance.clone(); + let mut colorized = source.clone(); + colorized.id = d.alloc_id(); + colorized.appearance.set_fill(Paint::solid(Color::rgb8(128, 128, 128))); + colorized.extras_mut().colorize = Some(Box::new(fill)); + for (j, n) in [compound, group, colorized].into_iter().enumerate() { + made.push((n.id, gradient)); + d.insert(Some(l), 2 + 3 * k + j, n).unwrap(); + } + } + Ok(()) + }) + .unwrap(); + for (id, gradient) in made { + run(&mut s, "select.set", json!({"ids": [id.0]})); + if gradient { + run(&mut s, "library.add", json!({"library": lib, "kind": "fillGradient"})); + let got = run(&mut s, "library.get", json!({"library": lib})); + let last = &got["gradients"].as_array().unwrap().last().unwrap()["gradient"]["gradient"]["stops"]; + assert_eq!(last.as_array().map(Vec::len), Some(3), "{id:?}: {got}"); + } else { + let added = run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"})); + assert_eq!(added["name"], "#123456", "{id:?}"); + } + } +} + /// A gradient added from an object that was rotated keeps the direction it shows there. #[test] fn a_gradient_from_rotated_art_keeps_its_direction() { diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index d4f553039..db3aad793 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -257,20 +257,19 @@ fn symbol_color_at(s: &mut Sampler, levels: &mut [Option], inst: &Node /// Whether the source has fill or stroke paint for an attribute sample. pub(crate) fn has_sampleable_paint(cx: &ToolContext, id: vectorcraft_doc::NodeId) -> bool { - cx.doc.node(id).is_some_and(|n| [false, true].into_iter().any(|stroke| n.proxy_paint(stroke, None).is_some_and(|(paint, ..)| !paint.is_none()))) + cx.doc.node(id).is_some_and(|n| [false, true].into_iter().any(|stroke| n.proxy_paint_only(stroke, None).is_some_and(|paint| !paint.is_none()))) } /// The colour of `n`'s fill (or its stroke, when the stroke was hit or there is no fill) at `p`, /// sampled in the paint's own space (text space for type runs) and box. A pattern gives the color /// of its tile art at `p` ([`pattern_color_at`]). fn paint_sample(s: &mut Sampler, levels: &mut [Option], n: &Node, on_stroke: bool, p: Point) -> Option { - let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none()); + let fill_none = n.proxy_paint_only(false, None).is_none_or(Paint::is_none); let stroke = on_stroke || fill_none; // Type takes its paint from its characters, and a colorized object from its colorize paint. let items: Vec = n.appearance.items.iter().enumerate().filter(|(_, it)| it.is_fill() != stroke).map(|(i, _)| i).collect(); if items.is_empty() || matches!(n.kind, NodeKind::Text(_)) || (!stroke && n.extras().colorize.is_some()) { - let (paint, to_doc, bounds) = n.proxy_paint(stroke, None)?; - return paint_color(s, levels, paint, to_doc, bounds, p); + return proxy_color(s, levels, n, stroke, None, p); } // The visible fills (or strokes) from the top: where one leaves a gap, the next shows. Of a // path's strokes, only the ones that reach `p` count, when any does (a click within the hit @@ -289,14 +288,26 @@ fn paint_sample(s: &mut Sampler, levels: &mut [Option], n: &Node, on_s } } for &i in visible.iter().rev() { - let Some((paint, to_doc, bounds)) = n.proxy_paint(stroke, Some(i)) else { continue }; - if let Some(c) = paint_color(s, levels, paint, to_doc, bounds, p) { + if let Some(c) = proxy_color(s, levels, n, stroke, Some(i), p) { return Some(c); } } None } +/// The color the paint behind `n`'s Fill (or Stroke) proxy for `item` ([`Node::proxy_paint`]) +/// shows at `p`. Only a gradient or a pattern has `n` measured for the paint's space and box. +fn proxy_color(s: &mut Sampler, levels: &mut [Option], n: &Node, stroke: bool, item: Option, p: Point) -> Option { + match n.proxy_paint_only(stroke, item)? { + Paint::None => None, + Paint::Solid { color, .. } => Some(*color), + _ => { + let (paint, to_doc, bounds) = n.proxy_paint(stroke, item)?; + paint_color(s, levels, paint, to_doc, bounds, p) + } + } +} + /// The color `paint`, in a space `to_doc` places in the document and with box `bounds` there, /// shows at `p`. A pattern gives the color of its tile art at `p` ([`pattern_color_at`]). fn paint_color(s: &mut Sampler, levels: &mut [Option], paint: &Paint, to_doc: Affine, bounds: Rect, p: Point) -> Option { @@ -1175,6 +1186,113 @@ mod tests { assert_eq!((at(100.0), at(94.0)), (Some("#ff0000".to_string()), Some("#0000ff".to_string()))); } + /// The color of `n`'s fill (or stroke) at `p` from its measured proxy paints: the reference + /// [`paint_sample`] is checked against. + fn measured_sample(s: &mut Sampler, levels: &mut [Option], n: &Node, on_stroke: bool, p: Point) -> Option { + let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none()); + let stroke = on_stroke || fill_none; + let items: Vec = n.appearance.items.iter().enumerate().filter(|(_, it)| it.is_fill() != stroke).map(|(i, _)| i).collect(); + if items.is_empty() || matches!(n.kind, NodeKind::Text(_)) || (!stroke && n.extras().colorize.is_some()) { + let (paint, to_doc, bounds) = n.proxy_paint(stroke, None)?; + return paint_color(s, levels, paint, to_doc, bounds, p); + } + let mut visible: Vec = items.into_iter().filter(|&i| n.appearance.items.get(i).is_some_and(|it| it.visible())).collect(); + if stroke && let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }, Some(bp)) = (&n.kind, n.stroke_path()) { + let reach = + |i: &usize| matches!(n.appearance.items.get(*i), Some(vectorcraft_doc::AppearanceItem::Stroke(st)) if st.hits(&bp, *rule, p, 0.0)); + if visible.iter().any(&reach) { + visible.retain(&reach); + } + } + for &i in visible.iter().rev() { + let Some((paint, to_doc, bounds)) = n.proxy_paint(stroke, Some(i)) else { continue }; + if let Some(c) = paint_color(s, levels, paint, to_doc, bounds, p) { + return Some(c); + } + } + None + } + + /// Samples and the check for paint to sample give what the measured proxy paints give, on + /// paths, compound paths, groups, type and symbol instances whose fills and strokes have no + /// paint, a solid color or a gradient, in the top row and the row below, with and without a + /// colorize paint. + #[test] + fn samples_give_the_measured_proxy_paints_colors() { + use std::sync::Arc; + use vectorcraft_doc::{AppearanceItem, CharStyle, FillLayer, StrokeLayer, TextObject}; + use vectorcraft_geom::Rect; + use vectorcraft_geom::shapes::rectangle; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let square = |id| Node::path(id, rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Default::default()); + let ids: Vec = (0..5).map(|_| d.alloc_id()).collect(); + let (outer, hole, inner) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let hole = Node::path(hole, rectangle(Rect::new(120.0, 120.0, 180.0, 180.0)), Default::default()); + let text = TextObject::point(Point::new(100.0, 150.0), "Type", CharStyle { size: 40.0, ..Default::default() }); + let objects = [ + square(ids[0]), + Node::new(ids[1], NodeKind::Compound { children: vec![Arc::new(square(outer)), Arc::new(hole)], rule: Default::default() }), + Node::group(ids[2], vec![Arc::new(square(inner))]), + Node::new(ids[3], NodeKind::Text(Box::new(text))), + Node::new(ids[4], NodeKind::SymbolInstance { symbol: "Missing".into(), xf: Affine::translate((100.0, 100.0)) }), + ]; + for (k, n) in objects.into_iter().enumerate() { + d.insert(Some(l), k + 1, n).unwrap(); + } + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let paints = [Paint::None, Paint::solid(Color::rgb8(200, 40, 10)), gradient]; + let points = [Point::new(150.0, 150.0), Point::new(100.0, 150.0), Point::new(130.0, 110.0)]; + let (sel, pd) = (Selection::default(), paint()); + let (mut checked, mut colored) = (0, 0); + for &id in &ids { + for top in &paints { + for below in &paints { + for colorize in [None, Some(&paints[1]), Some(&paints[2])] { + let mut d = d.clone(); + let n = d.node_mut(id).unwrap(); + n.appearance.items = vec![ + AppearanceItem::Fill(FillLayer::new(below.clone())), + AppearanceItem::Stroke(StrokeLayer::new(below.clone(), 4.0)), + AppearanceItem::Fill(FillLayer::new(top.clone())), + AppearanceItem::Stroke(StrokeLayer::new(top.clone(), 2.0)), + ]; + n.extras_mut().colorize = colorize.map(|c| Box::new(c.clone())); + if let NodeKind::Text(t) = &mut n.kind { + for run in &mut t.runs { + (run.style.fill, run.style.stroke, run.style.stroke_width) = (top.clone(), below.clone(), 2.0); + } + } + let cx = cx(&d, &sel, &pd); + let n = d.node(id).unwrap(); + let measured = + [false, true].into_iter().any(|stroke| n.proxy_paint(stroke, None).is_some_and(|(paint, ..)| !paint.is_none())); + assert_eq!(has_sampleable_paint(&cx, id), measured, "{n:?}"); + for on_stroke in [false, true] { + for p in points { + let mut s = Sampler { + doc: &d, + opts: sample_options(&cx), + raster_sample: 1, + symbol_nodes: 0, + work: MAX_WORK, + exhausted: false, + patterns: 0, + }; + let mut levels: Vec> = (0..MAX_NESTING).map(|_| None).collect(); + let sampled = paint_sample(&mut s, &mut levels, n, on_stroke, p); + assert_eq!(sampled, measured_sample(&mut s, &mut levels, n, on_stroke, p), "{n:?} stroke {on_stroke} at {p:?}"); + checked += 1; + colored += usize::from(sampled.is_some()); + } + } + } + } + } + } + assert!(colored > checked / 4 && colored < checked, "both answers come up: {colored} of {checked} gave a color"); + } + /// A gap between a pattern's tile art shows what lies below it, in the document and in the tile /// art of another pattern. #[test] diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index a27dfa3ec..ff05f1c7e 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1035,7 +1035,7 @@ fn contents_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, label: &str, se if characters { for (k, stroke) in [false, true].into_iter().enumerate() { let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 100.0 + 24.0 * k as f32, r.center().y - 9.0), vec2(18.0, 18.0)); - chip(ui, cr, n.proxy_paint(stroke, None).map_or(&Paint::None, |(p, ..)| p), false); + chip(ui, cr, n.proxy_paint_only(stroke, None).unwrap_or(&Paint::None), false); } } // An image's row has no members to target. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4211e36a1..eb2f630c3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -553,6 +553,7 @@ since 2026-10-01. - **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). - **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`. The menu item's enabled check measures nothing in a document with one artboard, and Save Gradient Preset's check reads the proxy's paint without measuring the object; both give the same answer as before. An artboard that no path's control points, grown by the farthest its strokes reach, can meet is found empty without measuring the path. With an open path of 200,000 anchors selected, the checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build; with a second, empty artboard, Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms. Tests `delete_empty_artboards_is_enabled_exactly_when_it_deletes_something`, `delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents`, `empty_artboard_checks_match_the_visual_bounds_on_varied_strokes`, `the_empty_artboard_check_matches_measuring_every_artboard`, `the_save_check_matches_saving_with_and_without_a_gradient`, `the_unmeasured_proxy_paint_is_the_measured_one` and `the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects`. + The Eyedropper, the Live Paint check for paint to sample, the Appearance panel's Characters chips and the colors the Libraries panel adds from the selection also read the proxy's paint without measuring the object; the Eyedropper measures it only to sample a gradient or a pattern, and the Libraries panel only for a gradient. The colors and paints are the same as before. With an open path of 200,000 anchors, the Live Paint check and the Eyedropper's sample for a point inside it take 0.004 ms together instead of 26.0 ms in a release build. Tests `samples_give_the_measured_proxy_paints_colors` and `colors_and_gradients_come_from_compound_paths_groups_and_colorized_objects`. - **Artboard history selection:** headless pointer/key tests in `panels/properties.rs` pass for panel New → Undo → Delete/Backspace/Properties, Duplicate → Undo → Delete, pruning removed indices and preserving valid multiselection. They assert board survivors, artwork and object selection, Move/Scale Artwork options, saved state and Undo/Redo. Full workspace CI passes. - **Artboard tab-switch follow-up:** changing the active document through its tab aligns the Artboard tool with the displayed board before Properties Delete; clicking the already-active tab preserves multiselection and Move/Scale Artwork options. Three headless `chrome::tests::artboard_document_tab_*` regressions cover actual tab A → B → A clicks followed by Properties Delete, surviving boards and guides, preserved artwork/symbols and Undo/Redo; controls cover same-tab multiselection, Move/Scale Artwork options and other tools. Historical focused validation on `dbd2497b` recorded one expected RED failure with two passing controls, then all three GREEN tests passed. All three also passed in historical full CI on `a0671777`. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. - **Artboard clean active-tab close (2026-10-15):** with the Artboard tool active, closing a clean active tab restores the surviving document's displayed board before Properties Delete. Nine targeted checks passed: three existing tab regressions, one clean close/Delete regression and five existing unsaved-document controls. The close regression checks the correct surviving board, preserved artwork/symbols and object selection, affected guides, one Delete edit, saved state and Undo/Redo. Three reviewed original full-app headless captures show the active tab before closing, the surviving document's shown board after closing, and the correct survivor after Properties Delete, using the actual tab × and Delete controls. From 591fc9b21ec81704c294a1b7708f9d39cf8cb2e2 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:54:49 -0500 Subject: [PATCH 06/92] Paste: cells copied from a spreadsheet paste as a table of type over gridlines Tab-separated rows with the same number of cells (quoted cells too) become a group of point type, one per cell, in columns and rows over 0.5 pt gridlines; other text pastes as before. Fixes #5296. --- crates/engine/src/cmd/clipboard.rs | 1 + crates/engine/src/cmd/clipboard/flavours.rs | 23 ++- crates/engine/src/cmd/clipboard/table.rs | 203 ++++++++++++++++++++ crates/engine/src/tests/clipflavours.rs | 35 ++++ docs/file-format-parity.md | 5 +- docs/mcp.md | 4 +- 6 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/clipboard/table.rs diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 1f2707fbe..6d1e7284c 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -11,6 +11,7 @@ mod flavours; pub(crate) mod resources; +mod table; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index f4c096d9b..1d6f2af3e 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -191,7 +191,7 @@ pub fn specs() -> Vec { "Load Text into Clipboard", [], None, - "{text, center?: [x, y]} replace the clipboard with point text of `text` (one paragraph per line; trailing line breaks dropped) in the default type style, centred on `center` (default: the first artboard) → {count}; then run edit.paste", + "{text, center?: [x, y], table?: true} replace the clipboard with point text of `text` (one paragraph per line; trailing line breaks dropped) in the default type style, centred on `center` (default: the first artboard) → {count}; then run edit.paste. Cells copied from a spreadsheet (tab-separated, at least two columns, every row with the same number of cells, cells in double quotes may hold tabs and line breaks; at most 1000 rows, 256 columns and 20,000 cells) become a group \"Table\": point type for each cell that holds text, in columns as wide as their widest cell and rows as tall as their tallest, over a group \"Gridlines\" of 0.5 pt black lines around every cell → {count, rows, columns}; table: false pastes them as plain text", has_doc, import_text ), @@ -491,9 +491,24 @@ fn import_text(s: &mut Session, p: &Value) -> Result { if text.trim().is_empty() { return Err(bad(C, "the text is empty")); } - let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null)); - t.system_layout = s.doc()?.doc.setup.system_layout; - crate::cmd::typecmd::refresh_bounds(&mut t); + let style = super::super::create::new_type_style(s, &Value::Null); + let system_layout = s.doc()?.doc.setup.system_layout; + let layout = |t: &mut TextObject| { + t.system_layout = system_layout; + crate::cmd::typecmd::refresh_bounds(t); + }; + let table = match p.get("table").filter(|v| !v.is_null()) { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad(C, "table must be true or false"))?, + }; + // Cells copied from a spreadsheet paste as a table of type over gridlines. + if let Some(rows) = table.then(|| super::table::parse(text)).flatten() { + let n = super::table::table(&rows, &style, layout); + let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?; + return Ok(json!({ "count": count, "rows": rows.len(), "columns": rows.first().map_or(0, Vec::len) })); + } + let mut t = TextObject::point(Point::ZERO, text, style); + layout(&mut t); let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t))); let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?; Ok(json!({ "count": count })) diff --git a/crates/engine/src/cmd/clipboard/table.rs b/crates/engine/src/cmd/clipboard/table.rs new file mode 100644 index 000000000..392be9895 --- /dev/null +++ b/crates/engine/src/cmd/clipboard/table.rs @@ -0,0 +1,203 @@ +//! Cells copied from a spreadsheet (Excel, Google Sheets, LibreOffice Calc, Numbers): the plain +//! text they put on the clipboard is tab-separated values, one row a line, every row with the +//! same number of cells. `clipboard.importText` pastes such text as a table: a group of point +//! type, one object for each cell that holds text, laid out in columns as wide as their widest +//! cell and rows as tall as their tallest, over gridlines around every cell. + +use std::sync::Arc; + +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; + +/// Most rows a pasted table takes; more pastes as plain text. +pub(crate) const MAX_ROWS: usize = 1000; +/// Most columns a pasted table takes; more pastes as plain text. +pub(crate) const MAX_COLUMNS: usize = 256; +/// Most cells (rows × columns) a pasted table takes; more pastes as plain text. +pub(crate) const MAX_CELLS: usize = 20_000; +/// Space between a cell's text and its gridlines, in points. +const PAD: f64 = 4.0; +/// The gridlines' stroke weight, in points. +const GRIDLINE: f64 = 0.5; + +/// The rows of `text` (line breaks already `\n`, trailing ones dropped) when it is a table copied +/// from a spreadsheet: tab-separated cells, at least two columns, every row with the same number +/// of cells, within the caps above. A cell in double quotes may hold tabs, line breaks and +/// doubled quotes (`""`), as the spreadsheets write them. `None` for any other text, which pastes +/// as it always has. +pub(crate) fn parse(text: &str) -> Option>> { + if !text.contains('\t') { + return None; + } + let mut rows: Vec> = vec![]; + let mut row: Vec = vec![]; + let mut cell = String::new(); + let mut chars = text.chars().peekable(); + // At the start of a cell, a quote opens a quoted cell. + let mut start = true; + let mut quoted = false; + let mut cells = 0usize; + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + chars.next(); + cell.push('"'); + } else { + quoted = false; + } + } else { + cell.push(c); + } + continue; + } + match c { + '"' if start => { + quoted = true; + start = false; + } + '\t' | '\n' => { + row.push(std::mem::take(&mut cell)); + cells += 1; + if row.len() > MAX_COLUMNS || cells > MAX_CELLS { + return None; + } + if c == '\n' { + rows.push(std::mem::take(&mut row)); + if rows.len() > MAX_ROWS { + return None; + } + } + start = true; + } + c => { + cell.push(c); + start = false; + } + } + } + // A quote left open is not a spreadsheet's. + if quoted { + return None; + } + row.push(cell); + rows.push(row); + if rows.iter().any(|r| r.len() > MAX_COLUMNS) || cells + 1 > MAX_CELLS { + return None; + } + let columns = rows.first()?.len(); + let fits = columns >= 2 && rows.len() <= MAX_ROWS && rows.iter().all(|r| r.len() == columns); + // The first and last columns hold something: text indented with tabs is not a table. + let used = |k: usize| rows.iter().any(|r| r.get(k).is_some_and(|c| !c.trim().is_empty())); + (fits && used(0) && used(columns - 1)).then_some(rows) +} + +/// `rows` laid out as a table (see the module docs) with its top left at the origin: one group +/// holding a group of gridlines and the cells' type. `style` is the type's style; `layout` +/// refreshes a type object's bounds. +pub(crate) fn table(rows: &[Vec], style: &CharStyle, layout: impl Fn(&mut TextObject)) -> Node { + let columns = rows.iter().map(Vec::len).max().unwrap_or(0); + let mut next = 0u64; + let mut id = || { + next += 1; + NodeId(next) + }; + // Each cell's type, at the origin, with its bounds. + let mut cells: Vec>> = vec![]; + for row in rows { + let mut out = vec![]; + for text in row { + if text.trim().is_empty() { + out.push(None); + continue; + } + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut t = TextObject::point(Point::ZERO, &text, style.clone()); + layout(&mut t); + let n = Node::new(id(), NodeKind::Text(Box::new(t))); + out.push(n.geometric_bounds().filter(|b| b.is_finite()).map(|b| (n, b))); + } + cells.push(out); + } + // A row with nothing in it is as tall as one line of the type. + let line = style.size.max(1.0) * 1.2; + let widths: Vec = + (0..columns).map(|k| cells.iter().filter_map(|r| r.get(k)?.as_ref()).map(|(_, b)| b.width()).fold(line, f64::max) + 2.0 * PAD).collect(); + let heights: Vec = cells.iter().map(|r| r.iter().flatten().map(|(_, b)| b.height()).fold(line, f64::max) + 2.0 * PAD).collect(); + let xs: Vec = std::iter::once(0.0) + .chain(widths.iter().scan(0.0, |x, w| { + *x += w; + Some(*x) + })) + .collect(); + let ys: Vec = std::iter::once(0.0) + .chain(heights.iter().scan(0.0, |y, h| { + *y += h; + Some(*y) + })) + .collect(); + let (right, bottom) = (xs.last().copied().unwrap_or(0.0), ys.last().copied().unwrap_or(0.0)); + let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), GRIDLINE); + let mut lines = vec![]; + for y in &ys { + lines.push(segment(id(), (0.0, *y), (right, *y), &stroke)); + } + for x in &xs { + lines.push(segment(id(), (*x, 0.0), (*x, bottom), &stroke)); + } + let mut grid = Node::group(id(), lines); + grid.name = Some("Gridlines".into()); + let mut children = vec![Arc::new(grid)]; + for (r, row) in cells.into_iter().enumerate() { + for (k, cell) in row.into_iter().enumerate() { + let (Some((mut n, b)), Some(x), Some(y)) = (cell, xs.get(k), ys.get(r)) else { continue }; + // The text's top left a pad inside the cell's. + n.transform(Affine::translate((x + PAD - b.x0, y + PAD - b.y0)), false); + children.push(Arc::new(n)); + } + } + let mut group = Node::group(id(), children); + group.name = Some("Table".into()); + group +} + +/// A straight stroked line from `a` to `b`. +fn segment(id: NodeId, a: (f64, f64), b: (f64, f64), stroke: &Appearance) -> Arc { + let mut p = BezPath::new(); + p.move_to(a); + p.line_to(b); + Arc::new(Node::path(id, PathData::from_bezpath(&p), stroke.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spreadsheet_cells_are_told_from_other_text() { + let rows = parse("Name\tQty\nApples\t3\nPears\t").unwrap(); + assert_eq!(rows, vec![vec!["Name", "Qty"], vec!["Apples", "3"], vec!["Pears", ""]]); + // Quoted cells hold line breaks, tabs and quotes. + let rows = parse("\"Two\nlines\"\t\"a\tb\"\n\"say \"\"hi\"\"\"\tx").unwrap(); + assert_eq!(rows, vec![vec!["Two\nlines", "a\tb"], vec!["say \"hi\"", "x"]]); + // Not tables: no tabs, ragged rows, indented text, an unclosed quote. + for t in ["plain words", "one\ntwo", "a\tb\nc", "\tindented\n\tcode", "a\t\"open", "x\t"] { + assert_eq!(parse(t), None, "{t:?}"); + } + // A quote inside a cell is just a character. + assert_eq!(parse("5\" pipe\t2").unwrap(), vec![vec!["5\" pipe", "2"]]); + } + + #[test] + fn tables_past_the_caps_paste_as_text() { + let wide = vec!["x"; MAX_COLUMNS + 1].join("\t"); + assert_eq!(parse(&wide), None); + let tall = vec!["a\tb"; MAX_ROWS + 1].join("\n"); + assert_eq!(parse(&tall), None); + let many = vec![vec!["c"; 100].join("\t"); MAX_CELLS / 100 + 1].join("\n"); + assert_eq!(parse(&many), None); + let fits = vec!["a\tb"; MAX_ROWS].join("\n"); + assert_eq!(parse(&fits).map(|r| r.len()), Some(MAX_ROWS)); + } +} diff --git a/crates/engine/src/tests/clipflavours.rs b/crates/engine/src/tests/clipflavours.rs index 313935c21..5955389ae 100644 --- a/crates/engine/src/tests/clipflavours.rs +++ b/crates/engine/src/tests/clipflavours.rs @@ -333,6 +333,41 @@ fn import_text_then_paste_gives_point_text() { } } +#[test] +fn spreadsheet_cells_paste_as_a_table_of_type_over_gridlines() { + let mut s = session(); + // As Excel puts three rows of two columns on the clipboard, an empty cell included. + let r = run(&mut s, "clipboard.importText", json!({"text": "Fruit\tQty\r\nApples\t3\r\nPears\t\r\n"})); + assert_eq!((r["count"].clone(), r["rows"].clone(), r["columns"].clone()), (json!(1), json!(3), json!(2))); + run(&mut s, "edit.paste", json!({"center": [200, 150]})); + let first = pasted(&s); + assert_eq!(first.len(), 1); + let table = &first[0]; + assert_eq!(table.name.as_deref(), Some("Table")); + let kids = table.children().unwrap(); + let grid = &kids[0]; + assert_eq!(grid.name.as_deref(), Some("Gridlines")); + assert_eq!(grid.children().unwrap().len(), 4 + 3, "four row lines, three column lines"); + // One type object a cell that holds text, each inside its own cell. + let cells: Vec<_> = kids[1..].iter().map(|n| (text_of(n).plain_text(), n.geometric_bounds().unwrap())).collect(); + let texts: Vec<&str> = cells.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(texts, ["Fruit", "Qty", "Apples", "3", "Pears"]); + let at = |t: &str| cells.iter().find(|c| c.0 == t).unwrap().1; + assert!(at("Qty").x0 > at("Apples").x1, "the second column starts past the widest of the first"); + assert!((at("Qty").x0 - at("3").x0).abs() < 1e-6 && (at("Fruit").x0 - at("Pears").x0).abs() < 1e-6, "columns line up"); + assert!(at("Apples").y0 > at("Fruit").y1 && at("Pears").y0 > at("Apples").y1, "rows go down: {cells:?}"); + let b = table.geometric_bounds().unwrap(); + assert!((b.center().x - 200.0).abs() < 1.0 && (b.center().y - 150.0).abs() < 1.0, "{b:?}"); + // table: false, and text that isn't a spreadsheet's, paste as one point type object. + for (text, table) in [("a\tb\nc\td", json!(false)), ("\tindented\n\tcode", Value::Null), ("x\ty\nz", Value::Null)] { + run(&mut s, "clipboard.importText", json!({"text": text, "table": table})); + run(&mut s, "edit.paste", json!({})); + let p = pasted(&s); + assert_eq!((p.len(), text_of(&p[0]).plain_text()), (1, text.to_string()), "{text:?}"); + } + assert!(s.execute("clipboard.importText", &json!({"text": "a\tb", "table": "yes"})).is_err()); +} + #[test] fn type_only_copies_offer_their_text() { let mut s = session(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..971dfa016 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Paste: cells copied from a spreadsheet paste as a table of point type over gridlines, #5296; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -177,7 +177,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | CSS | export (CSS Properties) | — | yes | | | OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) | | Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) | -| Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art | +| Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art; cells copied from a spreadsheet (tab-separated text) paste as a table of point type over gridlines, at most 1000 rows, 256 columns and 20,000 cells (#5296) | ## PDF editable imports @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 91589c619..ee359ccdf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4308,7 +4308,9 @@ layer's objects hidden, or the page's objects when `document.open` would not use compatibility comes in from its editing data the same way with or without `editingData`), `clipboard.importEps {dataBase64}` (an EPS or other PostScript picture's objects, as `document.open` reads one, a hidden layer's objects hidden), `clipboard.importImage {dataBase64, mime?}` (an embedded image at 100% of its physical size) or -`clipboard.importText {text}` (point text in the default type style), each centred on `center` +`clipboard.importText {text, table?}` (point text in the default type style; cells copied from a spreadsheet, +tab-separated rows with the same number of cells, become a group `Table` of point type per cell over 0.5 pt +`Gridlines`, unless `table: false`), each centred on `center` (default: the first artboard); then any `edit.paste*` command pastes it. The desktop app does this itself: Copy and Cut publish the formats to the system clipboard, and a Paste outside text editing first reads what another app copied (SVG, then PDF, then text, then a bitmap; SVG markup in text counts as SVG). A background thread makes the From fc099e849c7ca163fbdabbf02799fc8606e4352c Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:35:40 -0500 Subject: [PATCH 07/92] Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835) A straight segment dragged with Direct Selection or Group Selection snapped the point it was pressed at to a grid intersection, leaving both of its anchors off the grid. With Snap to Grid or Snap to Pixel on, the box round the moved anchors now lands an edge on a gridline on each axis, as a moved object's does; Shift keeps the move on its line. A dragged anchor still lands on an intersection. --- crates/tools/src/direct.rs | 33 ++++++++++++++++++++++++-------- crates/tools/src/direct/tests.rs | 23 ++++++++++++++++++++++ docs/target-app-parity.md | 4 +++- docs/ui-parity.md | 5 +++-- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index d7bd9c495..3c128d0a1 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -67,12 +67,14 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool enum State { Idle, /// Dragging the selected anchors, which move alike: `grab` is where the anchor pressed on (or - /// the point of a straight segment nearest the pointer pressed on it) was, the point that - /// snaps. + /// the point of a straight segment nearest the pointer pressed on it, `segment`) was, the + /// point that snaps. With Snap to Grid or Snap to Pixel, a segment's drag snaps its anchors + /// instead (#3835). MoveAnchors { start: Point, grab: Point, began: bool, + segment: bool, }, /// Moving (Alt: copying) the selected objects; `grab`: the anchor an Alt-press picked the /// path by, which snaps as a dragged anchor does; else `hold`, where the press took hold of @@ -849,7 +851,9 @@ impl Tool for DirectSelectionTool { self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false, whole: ev.mods.alt }; vec![] } - (PointerKind::Drag, State::MoveAnchors { start, grab, began }) => self.drag_move_anchors(cx, ev, p, (start, grab, began)), + (PointerKind::Drag, State::MoveAnchors { start, grab, began, segment }) => { + self.drag_move_anchors(cx, ev, p, (start, grab, began, segment)) + } (PointerKind::Drag, State::Segment { id, si, seg, t, start, began }) => { let mut out = vec![]; if !began { @@ -1288,7 +1292,7 @@ impl DirectSelectionTool { self.state = if already && cx.selection.anchors.is_empty() { State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None } } else { - State::MoveAnchors { start: p, grab, began: false } + State::MoveAnchors { start: p, grab, began: false, segment: false } }; if ev.mods.shift { return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))]; @@ -1354,7 +1358,7 @@ impl DirectSelectionTool { } else { // Held by the segment itself, so the segment is what lands on a // ruler guide or lines up, not the pressed spot beside it (#3701). - State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false } + State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false, segment: true } }; if ev.mods.shift { return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]], "mode": "toggle"}))]; @@ -1381,23 +1385,36 @@ impl DirectSelectionTool { } vec![] } - fn drag_move_anchors(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (start, grab, began): (Point, Point, bool)) -> Vec { + fn drag_move_anchors( + &mut self, + cx: &ToolContext, + ev: &PointerEvent, + p: Point, + (start, grab, began, segment): (Point, Point, bool, bool), + ) -> Vec { let mut out = vec![]; if !began { if p.distance(start) < cx.tol(3.0) { return out; } out.push(Action::Begin("Move".into())); - self.state = State::MoveAnchors { start, grab, began: true }; + self.state = State::MoveAnchors { start, grab, began: true, segment }; self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); self.line_end = line_end_leave(cx, grab); + // A segment held by a point along it lands its anchors on the grid (the box round + // them, an edge on a gridline on each axis), not that point (#3835). + let grid = segment && (cx.snap_to_grid || cx.snap_to_pixel); + self.partial_grid = + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); } let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let unsnapped = d; // The grabbed anchor snaps and the others follow it. Shift keeps the move at its // angle, and the anchor slides along it onto what Smart Guides find there (#886). self.guides.clear(); - if let Some(snap) = &self.anchor_snap { + if segment && let Some(grid) = &self.partial_grid { + (d, self.guides) = if ev.mods.shift { grid.snap_along(cx, grab, d, false) } else { grid.snap(cx, grab, d, false) }; + } else if let Some(snap) = &self.anchor_snap { let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref().or(self.line_end.as_ref())); (d, self.guides) = (q - grab, guides); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index c1748cac9..c249bd40a 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -1862,6 +1862,29 @@ fn a_move_of_objects_lands_an_edge_on_the_grid() { } } +/// #3835: with Snap to Grid on, a straight segment dragged by a point along it lands its anchors +/// on the grid, not that point. B's top edge, (300, 300)–(400, 300), pressed at (350, 300) and +/// dragged by (22, 23), moves its anchors to 322–422 across and 323 down: the right end is nearest +/// the gridline 423 and the edge nearest 324, so the move is (23, 24). Snapping the pressed point +/// instead would move it by (19, 24), onto the intersection (369, 324), leaving both anchors off +/// the grid. An anchor pressed on still lands on an intersection itself. +#[test] +fn a_dragged_segment_lands_its_anchors_on_the_grid() { + let (d, _, b) = two_squares(); + let p = paint(); + let mut s = anchors_of(b, &[(0, 0), (0, 1)]); + s.segments.insert(b, [(0, 0)].into_iter().collect()); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let mut t = DirectSelectionTool::new(false); + assert_eq!(drag(&mut t, &c, (350.0, 300.0), (372.0, 323.0), Mods::default()).0, json!({"dx": 23.0, "dy": 24.0})); + let (v, _) = drag(&mut t, &c, (350.0, 300.0), (363.0, 301.0), Mods { shift: true, ..Mods::default() }); + let (x, y) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap()); + assert!((x - 14.0).abs() < 1e-9 && y.abs() < 1e-9, "{v}"); + let one = anchors_of(b, &[(0, 0)]); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &one, &p) }; + assert_eq!(drag(&mut t, &c, (300.0, 300.0), (322.0, 323.0), Mods::default()).0, json!({"dx": 24.0, "dy": 24.0})); +} + /// Group Selection and Direct Selection on a whole path keep a Shift-constrained move on its /// line when it snaps, as the Selection tool does: level by 13.04 with Snap to Grid on, B's /// right side (413.04) lands on the gridline 414, and its top stays at 300, between 297 and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb2f630c3..7b7484c59 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-06 · **Change:** minor (Snap to Glyph's glyph bounds give corners and turn with rotated type, #5112; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid, #3835; 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) ## Feature submission evidence @@ -1088,6 +1088,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. The Separations Preview panel's Gamut Check button reports one color outside the CMYK gamut in the singular and more in the plural, in English and in each language's plural forms (`gamut_check_status_takes_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). - **Document Color Mode outside the layers:** File › Document Color Mode (in one undo step) and `document.open {colorMode}` also convert the colors of graph designs, graphic styles, opacity-mask art, the tile that pattern editing's Cancel puts back and the art of brushes (with a Hue Shift brush's key color); a tint of a global swatch there becomes the converted swatch's color at its tint, as in the art. + - **Segments dragged onto the grid (#3835):** with View › Snap to Grid (or Snap to Pixel) on, a straight segment dragged with Direct Selection or Group Selection lands its anchors on the grid, the box round them putting an edge on a gridline on each axis as a moved object's does (Shift keeps the move on its line), instead of the point it was pressed at landing on an intersection and leaving both anchors off the grid; a dragged anchor still lands on an intersection itself, and drawn points and resize handles already snapped (`direct::tests::a_dragged_segment_lands_its_anchors_on_the_grid`). - **Spinner arrows in the shadow and glow dialogs (#5367, #5368):** Drop Shadow's Opacity, X Offset, Y Offset, Blur and Darkness and the Outer and Inner Glow's Opacity and Blur have up and down arrows beside the field, as the other number fields with arrows do: a click steps by one (a unit for distances; Shift ×10, Ctrl/Cmd ÷10), holding repeats, Opacity and Darkness stop at 0 and 100 % and Blur at 0 (`dialogs::effect` test `drop_shadow_and_outer_glow_fields_have_spinner_arrows`). - **Colour swatches in effect dialogs (#5365):** a colour parameter of an effect dialog (Outer Glow's Color, Inner Glow's, Drop Shadow's) shows a swatch beside its hex field that follows the value typed in it; a double-click on the swatch opens the Color Picker over the dialog on that colour, whose OK fills the field and whose Cancel comes back unchanged, and the dialog previews the colour when it returns (`dialogs::effect` test `outer_glow_colour_swatch_opens_the_color_picker`). Drop Shadow draws Color beside its Darkness choice rather than in the shared grid, so its swatch came separately (#5364; `drop_shadow_colour_swatch_opens_the_color_picker`); with Darkness chosen it is dimmed. - **Document Color Mode for effects and graphs:** File › Document Color Mode and `document.open {colorMode}` convert the colors of effects (Drop Shadow, Inner Glow, Outer Glow and the other effects with a color, on objects, their fills and strokes and in graphic styles) and the series paints and label styles that a graph keeps, so a graph drawn again from new data keeps the converted colors; File › Document Color Mode also converts the fill and stroke that new art takes. Undo restores the original new-art fill and stroke and Redo applies the conversion again. @@ -1704,6 +1705,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: their number fields have spinner arrows (#5367, #5368); evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 070c67864..35762b438 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow: Color's swatch opens the Color Picker, #5364; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid, #3835; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -38,6 +38,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color follows the hex and a double-click opens the Color Picker over the dialog, whose OK fills Color (#5364); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: spinner arrows beside Opacity, the offsets, Blur and Darkness (#5367, #5368); scores and estimates unchanged | | 2026-11-06 | minor | Rename (`ui.rename`, no default key) opens the Layers panel's name field on the selected layer, group or object's row, as double-clicking its name does; a key can be assigned in Keyboard Shortcuts (#5324, a VectorCraft addition); scores and estimates unchanged | @@ -206,7 +207,7 @@ tools, never checked side by side) isn't captured row by row. | Spacing guides (equal distances) | partial | for moves and bounding-box resizes (#394); not while drawing | | Distance Guides: Alt/Option over another object, or an empty part of an artboard, shows the selection's distances to it (Smart Guides › Invoke Distance Guides) | done | Selection tool hover with Smart Guides on: the gap between facing edges, else the offsets of matching edges, to another object; the left, right, top and bottom distances to the artboard; labelled in the General unit. The selection is measured as a whole. Nothing while moving or nudging, which the public pages don't describe. [Use Distance Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html), [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) | | Snapping tolerance preference | done | | -| Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | #1840: with Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1816 waits for the reporter) | +| Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | #1840: with Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1816 waits for the reporter); #3835: a straight segment dragged with Direct Selection lands its anchors on the grid, the box round them putting an edge on a gridline on each axis, as a moved object does | | Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`); new type and drawing snap to live-text baselines and glyph bounds when Smart Guides are on (#3124). Character panel menu › Show Snap to Glyph Options: a Snap to Glyph checkbox (View › Snap to Glyph), then a checkbox for each guide (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point), dimmed while Snap to Glyph is off (#4232); showing them scrolls the panel to them (#5273) | | Tangent and perpendicular snapping for line endpoints (new in 30.0) | done | #2530: tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Line Segment, Pen and Curvature tools, and for an existing line whose end anchor is dragged with Direct Selection (the only anchor selected, an open path's end on a straight segment; Shift keeps its angle step instead; Group Selection moves the path whole and does not snap so); mid-line anchors, curved segments' ends and curves as perpendicular targets are excluded, as the pages say. Saved Preferences › Smart Guides › Geometric Guides controls, on by default. [Perpendicular](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-perpendicular.html) and [tangent](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-tangent.html) pages; test `dragged_line_ends_snap_perpendicular_to_segments_and_tangent_to_curves` | | Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set; no View › Snapping submenu. Floating-panel parity remains unverified. | From 090667fb26e3226c65ae5d6dcb6796aaed8150c7 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 6 Nov 2026 18:37:46 +0500 Subject: [PATCH 08/92] Languages: Bulgarian, with its quote marks and the fonts' Bulgarian forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulgarian was not among the text languages, so Bulgarian text could not be tagged and fonts drew their Russian-style Cyrillic. Bulgarian (bg) is now in the language list (Character panel, character styles, text.setFormat by name or tag, the Hyphenation preference's default language), with CLDR's quotes, „…“ at either level. Shaped as bg, the fonts' locl forms for BGR apply: in the bundled Source Sans 3 Italic, в, д, ж, з, к, л and ю take their Bulgarian forms. Fixes #5945 --- crates/doc/src/setup.rs | 10 ++++++++++ crates/engine/src/cmd/prefscmds.rs | 1 + crates/text/src/features.rs | 23 +++++++++++++++++++++++ docs/type-parity.md | 5 +++-- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index c26918153..58b711582 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -164,6 +164,9 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[ ("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }), ("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), ("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }), + // „…“ at either level: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/bg.xml, + // `delimiters`). Shaped as `bg`, fonts draw their Bulgarian forms (`locl` for `BGR`, #5945). + ("Bulgarian", "bg", Quotes { double: ['„', '“'], single: ['„', '“'] }), // „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN // 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR // (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`). @@ -385,6 +388,10 @@ mod tests { assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl"))); assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn"))); assert_eq!(char_language("Croatian"), Ok(Some("hr"))); + // Bulgarian (#5945), by name or tag. + assert_eq!(char_language("Bulgarian"), Ok(Some("bg"))); + assert_eq!(char_language("BG"), Ok(Some("bg"))); + assert_eq!(char_language_name(Some("bg")), Some("Bulgarian")); assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic")); // Every entry has a tag of its own, and a name of its own. for (i, (n, t, _)) in LANGUAGES.iter().enumerate() { @@ -409,6 +416,9 @@ mod tests { } let mut prev = None; assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“"); + // Bulgarian: „…“ at either level, as CLDR has it. + let mut prev = None; + assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“"); // Converting in pieces keeps the context. let mut prev = None; let a = Quotes::ENGLISH.apply("\"", &mut prev); diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 029f3608f..b073d7ec3 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -277,6 +277,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ ("pl", "Polish"), ("cs", "Czech"), ("ru", "Russian"), + ("bg", "Bulgarian"), ("sr-Cyrl", "Serbian: Cyrillic"), ("sr-Latn", "Serbian: Latin"), ("hr", "Croatian"), diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index eac3b3c3f..ddf9e5a03 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -391,6 +391,29 @@ mod tests { assert_eq!(per_run, [2, 1]); } + /// Bulgarian text gets the font's Bulgarian forms (`locl` for `BGR`, #5945): in the bundled + /// Source Sans 3 Italic, в, д, ж, з, к, л and ю differ from the Russian ones, and letters + /// the font draws alike stay alike. + #[test] + fn bulgarian_gets_the_fonts_bulgarian_forms() { + let st = |language: &str| CharStyle { + font_family: "Source Sans 3".into(), + font_style: "Italic".into(), + language: Some(language.to_string()), + ..Default::default() + }; + // Each letter laid out on its own, so one letter's advance doesn't move the next. + let outline = |c: char, language| { + layout(FontDb::global(), &TextObject::point(Point::ZERO, &c.to_string(), st(language))) + .glyphs + .into_iter() + .map(|g| g.outline) + .collect::>() + }; + let differs: String = "вгджзийклптцшщюьъ".chars().filter(|c| outline(*c, "bg") != outline(*c, "ru")).collect(); + assert_eq!(differs, "вджзклю"); + } + /// Serbian text gets the font's Serbian forms (`locl` for `SRB`): in the bundled Source Sans /// 3 Italic, б, г, д, п, т differ from the Russian ones, in either Serbian list entry. #[test] diff --git a/docs/type-parity.md b/docs/type-parity.md index e5f51dd9d..5c64183be 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Alt + numeric-keypad codes type their character on Windows, and `type.insert` takes a code, #5108; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -66,7 +66,7 @@ against Illustrator's public documentation; **estimated** unless noted. | 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: 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 | -| Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation and spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set` | +| Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian; Bulgarian, #5945); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation and spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set` | | No Break | done | Character panel menu (`text.setFormat` / `text.setRangeStyle {noBreak}`): the marked characters stay on one line, including across a space; a run that does not fit after earlier text moves together, and a run longer than the line stays together. Evidence: `layout::wrapping_tests::no_break_keeps_marked_characters_on_one_line`, `tests::no_break_keeps_marked_words_on_one_line`, `typecmd::no_break_is_a_character_attribute_saved_only_when_on` | | Inline symbols in text | beyond Illustrator | not scored | | Align Stroke (inside, outside) on live type's character stroke | beyond Illustrator | not scored; `stroke.set {align}` and `text.setRangeStyle {strokeOptions: {align}}`; saved natively, drawn around the glyphs on the canvas and in PDF, EPS and SVG (live text, stroke on the glyph outlines); visual bounds grow with it (#2294) | @@ -93,6 +93,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | 2026-11-01 | minor | The Type and Vertical Type tools make area type in a clicked closed path and type on a path along a clicked open one, with their own pointers; each type tool has its own icon; scores unchanged (estimated) | | 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; scores unchanged | | 2026-11-01 | minor | Glyphs panel (#4908): the character selected with the Type tool (a range's first, else the one before the caret) is highlighted in the grid and scrolled into view when the selection moves to another character; Show › Alternates for Current Selection not yet; scores unchanged | +| 2026-11-06 | minor | Bulgarian (`bg`) in the language list, with CLDR's „…“ at either quote level; Bulgarian text gets the fonts' Bulgarian forms (`locl` for `BGR`: in Source Sans 3 Italic в, д, ж, з, к, л, ю) (#5945); scores unchanged | | 2026-11-01 | minor | Serbian: Cyrillic (`sr-Cyrl`), Serbian: Latin (`sr-Latn`) and Croatian (`hr`) in the language list, with their quote marks (Serbian „…” and ’…’ per the Pravopis srpskoga jezika, Matica srpska 2010, ISBN 978-86-7946-079-0, т. 168, and [CLDR `sr`](https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml); Croatian „…“ and ‚…‘ per [CLDR `hr`](https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml)); Serbian text gets the fonts' Serbian forms (italic б, г, д, п, т); scores unchanged | | 2026-11-01 | minor | Area Type Options › Text Flow (#4978): By Rows / By Columns radio buttons and `text.areaOptions {flow}`; area type with several rows and columns fills them across each row first or down each column first (the default, so existing documents are unchanged; saved only when by rows); scores unchanged | | 2026-11-01 | minor | Digits (#4942): the native digits of Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada and Malayalam in the Character panel menu › Digits and `{digits}`; scores unchanged | From d45ebe465bfc074350c49337956e7aa6088e8ab4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 09:48:16 -0800 Subject: [PATCH 09/92] .ai save: the editing copy names the active artboard The editing copy of a .ai or .ait file that VectorCraft saves or exports now has `%_ /Int (CropAreaActive) ,` after the ArtboardArray in its recorded part, and marks that artboard as the selected one. The app records its view's active artboard before each command (DocState::active_artboard; an index past the last artboard is the last, as in the Artboards panel); without a view, the artboard the document opened on (Document::opened_artboard) is written, else the first. A .ai file read from its editing copy opens on that artboard. Before, the copy named no active artboard and always marked the first as selected. Docs: docs/mcp.md, docs/file-format-parity.md, the editingData option text. --- crates/doc/src/lib.rs | 4 +- crates/engine/src/cmd/fileio/export.rs | 6 +- crates/engine/src/cmd/fileio/native.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 16 +++++- .../engine/src/cmd/fileio/tests_aiimport.rs | 55 +++++++++++++++++++ crates/engine/src/doc_state.rs | 6 ++ crates/eps/src/ai_editing.rs | 11 ++-- crates/eps/src/ai_editing/tests.rs | 29 +++++++++- crates/ui-egui/src/app/commands.rs | 3 + crates/ui-egui/src/io.rs | 45 ++++++++++++++- crates/ui-egui/src/io/save.rs | 15 ++++- docs/file-format-parity.md | 4 +- docs/mcp.md | 5 ++ 13 files changed, 185 insertions(+), 16 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 988df4c51..6d069872d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -326,8 +326,8 @@ pub struct Document { #[serde(skip)] pub retired_artboard_id: RetiredArtboardId, /// The id of the artboard an imported file names as its active one (a `.ai` file's - /// `CropAreaActive`): the document opens with that artboard active. `None`: the first. Never - /// saved. + /// `CropAreaActive`): the document opens with that artboard active. A `.ai` save or export + /// names it as the file's active artboard. `None`: the first. Native files do not keep it. #[serde(skip)] pub opened_artboard: Option, /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 852f2c735..77adaeac9 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -190,7 +190,11 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C return Ok((Cow::Owned(doc), display_doc_title)); } let st = s.doc()?; - let doc = export_source(st, p)?; + let mut doc = export_source(st, p)?; + // A `.ai` export names the view's active artboard, as a `.ai` save does. + if let Some(id) = super::save::moved_active_artboard(st, &doc, f) { + doc.to_mut().opened_artboard = Some(id); + } let display_doc_title = super::has_own_title(&doc); Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title)) } diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 4d293fe69..770c11644 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "editingData", ty: "boolean", default: "true", - description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", + description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", }, FormatOption { name: "aiVersion", diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index b594c51b8..55fcbaa08 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -447,22 +447,34 @@ fn is_svg(f: &Format) -> bool { } /// The document as written: native files carry the view to reopen at and the Layers panel's open -/// rows and transparency grid visibility. +/// rows and transparency grid visibility; a `.ai` file names the view's active artboard +/// ([`DocState::active_artboard`]) as its active one. fn doc_to_save(st: &DocState, f: &Format) -> Arc { if !is_native(f) { return st.doc.clone(); } let open = st.layers_open.saved(&st.doc); - if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid { + let active = moved_active_artboard(st, &st.doc, f); + if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid && active.is_none() { return st.doc.clone(); } let mut d = (*st.doc).clone(); + if active.is_some() { + d.opened_artboard = active; + } d.last_view = st.view.clone(); d.transparency_grid = st.transparency_grid; d.layers_open = open; Arc::new(d) } +/// The artboard a `.ai` or `.ait` file written from `st` (`doc`, the document it writes) names as +/// its active one, when that is not already the one `doc` opened on: the view's active artboard +/// ([`DocState::active_artboard`]), if `doc` has it. +pub(super) fn moved_active_artboard(st: &DocState, doc: &Document, f: &Format) -> Option { + st.active_artboard.filter(|id| matches!(f.id, "ai" | "ait") && doc.artboards.iter().any(|a| a.id == *id) && doc.opened_artboard != Some(*id)) +} + /// What a format loses against a native file (reported whenever a save writes it). fn fidelity_warning(f: &Format) -> Option { (!is_native(f)).then(|| { diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 0aa1733d8..e9e24e5a1 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -148,6 +148,61 @@ fn an_ai_file_opens_with_its_active_artboard() { assert_eq!(d.opened_artboard, Some(d.artboards[1].id), "{r}"); } +/// A `.ai` save names the view's active artboard in its editing copy, else the one the document +/// opened on, else the first; the file read from that copy opens on it. +#[test] +fn an_ai_save_names_its_active_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let ids: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect(); + let opts = LoadOptions { native: false, ..Default::default() }; + let active = |s: &mut Session, format: &str| { + let cmd = if format == "ait" { "file.saveAsTemplate" } else { "file.saveCopy" }; + let saved = s.execute(cmd, &json!({"format": format})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let l = load::load_with(&format!("a.{format}"), &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert_eq!(l.doc.artboards.len(), 3); + l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0) + }; + assert_eq!(active(&mut s, "ai"), 0); + // The view's. + s.doc_mut().unwrap().active_artboard = Some(ids[2]); + assert_eq!((active(&mut s, "ai"), active(&mut s, "ait")), (2, 2)); + // The one the document opened on, when the view names none or an artboard it doesn't have. + Arc::make_mut(&mut s.doc_mut().unwrap().doc).opened_artboard = Some(ids[1]); + for view in [None, Some(99)] { + s.doc_mut().unwrap().active_artboard = view; + assert_eq!(active(&mut s, "ai"), 1, "{view:?}"); + } + // The view's first artboard over the one the document opened on. + s.doc_mut().unwrap().active_artboard = Some(ids[0]); + assert_eq!(active(&mut s, "ai"), 0); + assert_eq!(s.doc().unwrap().doc.opened_artboard, Some(ids[1]), "the document is unchanged"); +} + +/// A `.ai` export (`document.serialize`, `document.export`, with or without `selectedOnly`) names +/// the view's active artboard in its editing copy, as a save does. +#[test] +fn an_ai_export_names_its_active_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let ids: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect(); + s.doc_mut().unwrap().active_artboard = Some(ids[2]); + let opts = LoadOptions { native: false, ..Default::default() }; + for (cmd, selected) in [("document.serialize", false), ("document.serialize", true), ("document.export", false)] { + let out = s.execute(cmd, &json!({"format": "ai", "selectedOnly": selected})).unwrap(); + let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap(); + let l = load::load_with("a.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{cmd}: {:?}", l.warnings); + let active = l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)); + assert_eq!(active, Some(2), "{cmd} {selected}"); + } + assert_eq!(s.doc().unwrap().doc.opened_artboard, None, "the document is unchanged"); +} + #[test] fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() { let live = format!("0 0 1 0 k\n{}f", ai::rect(10.0, 10.0, 14.0, 14.0)); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 3d1c4be89..5c3a9130d 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -76,6 +76,11 @@ pub struct DocState { /// The view saved into native files (`Document::last_view`); the UI keeps it current before a /// save and restores it when the document opens. pub view: Option, + /// The id of the view's active artboard, which the UI keeps current before each command it + /// runs: a `.ai` save or export names that artboard as the file's active one. `None`, or an + /// artboard the document no longer has: the artboard the document opened on + /// ([`vectorcraft_doc::Document::opened_artboard`]), else the first. + pub active_artboard: Option, /// Restored by Data Recovery: the title says "[Recovered]" and Save asks where to save it /// (suggesting `path`, the file it was copied from) instead of overwriting that file. pub recovered: bool, @@ -266,6 +271,7 @@ impl DocState { save_artboards: vec![], converted: false, view, + active_artboard: None, recovered: false, recovery: None, print_tiling: false, diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 26293842c..b6ea72eea 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -431,10 +431,12 @@ fn ruler_units(u: Unit) -> u8 { /// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on /// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio /// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the -/// first) and its name; then the canvas's size. Every record holds all of these entries. The -/// artboards and the canvas size are written in the `/Recorded` part of the `/Document` -/// dictionary, followed by an empty `/NotRecorded` part. +/// active one) and its name; then the index of the active artboard (`CropAreaActive`: the one +/// [`Document::opened_artboard`] names, else the first) and the canvas's size. Every record holds +/// all of these entries. The artboards, the active artboard and the canvas size are written in the +/// `/Recorded` part of the `/Document` dictionary, followed by an empty `/NotRecorded` part. fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { + let active = doc.opened_artboard.and_then(|id| doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0); s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); for (i, ab) in doc.artboards.iter().enumerate() { let r = to_art.transform_rect_bbox(ab.rect); @@ -449,10 +451,11 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 }); // Display marks: 1 the center mark, 2 cross hairs, 4 video safe areas. let mark = u8::from(ab.show_center_mark) | u8::from(ab.show_cross_hairs) << 1 | u8::from(ab.show_video_safe_areas) << 2; - let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0)); + let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == active)); let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes())); } s.push_str("%_; (ArtboardArray) ,\n"); + let _ = writeln!(s, "%_{active} /Int (CropAreaActive) ,"); let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); } diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 529c54a5f..c3262ac98 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -367,9 +367,9 @@ fn add_rect(doc: &mut Document, r: Rect) { doc.insert(Some(layer), usize::MAX, rect).unwrap(); } -/// The end of the document data: the artboards and the canvas size in the `/Recorded` part of the -/// `/Document` dictionary, then an empty `/NotRecorded` part. -const RECORDED: &str = "%_; (ArtboardArray) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"; +/// The end of the document data: the artboards, the active artboard (the first) and the canvas +/// size in the `/Recorded` part of the `/Document` dictionary, then an empty `/NotRecorded` part. +const RECORDED: &str = "%_; (ArtboardArray) ,\n%_0 /Int (CropAreaActive) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"; /// One letter-size artboard: the art 36 points in from its top-left corner, the artboard's record /// and the boxes, with the artboards in the `/Recorded` part of the `/Document` dictionary. @@ -984,6 +984,29 @@ fn artboard_display_marks_are_written() { assert!(ab.show_center_mark && !ab.show_cross_hairs && ab.show_video_safe_areas); } +/// The active artboard, the one `opened_artboard` names (else the first), is the selected one and +/// its index follows the artboards in the recorded part; the editing data reads back with that +/// artboard active. +#[test] +fn the_active_artboard_follows_the_artboards() { + let mut doc = Document::new(200.0, 100.0); + for (id, x0) in [(2, 250.0), (3, 500.0)] { + doc.artboards.push(Artboard { id, rect: Rect::new(x0, 0.0, x0 + 200.0, 100.0), ..doc.artboards[0].clone() }); + } + let first = doc.artboards[0].id; + // None, the first, the third, and an artboard the document doesn't have (the first). + for (opened, index) in [(None, 0), (Some(first), 0), (Some(3), 2), (Some(9), 0)] { + doc.opened_artboard = opened; + let p = program(&doc); + let entry = format!("%_; (ArtboardArray) ,\n%_{index} /Int (CropAreaActive) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n"); + assert!(p.contains(&entry), "{opened:?}: {p}"); + assert_eq!(p.matches("%_1 /Bool (IsArtboardSelected) ,\n").count(), 1, "{opened:?}"); + let back = read_back(&doc); + let active = back.opened_artboard.and_then(|id| back.artboards.iter().position(|a| a.id == id)).unwrap_or(0); + assert_eq!((back.artboards.len(), active), (3, index), "{opened:?}"); + } +} + /// A guide is its path followed by `(n) *` (closed) or `(N) *` (open), with no `(N) *` before /// it, and reads back as the same guide. #[test] diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 0652e6d9a..a21411587 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -173,6 +173,9 @@ impl VectorcraftApp { return r; } } + // A `.ai` file a command writes (a save, an export, a batch's save) names the view's + // active artboard. + io::remember_active_artboard(self); // Native files carry the view they reopen at. if fileio::SaveMode::of(id).is_some() { io::remember_view(self); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 4818a0c15..d394d8e6c 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -28,7 +28,7 @@ pub use open::{ }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; -pub use save::{ask_format_options, ask_pdf_options, remember_view, save, save_pdf}; +pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; #[cfg(test)] pub(crate) use save::{report_write_error, saved_status}; @@ -164,6 +164,49 @@ mod tests { assert!(!app.session.doc().unwrap().is_dirty()); } + /// A `.ai` file saved from the app names the view's active artboard as its active one, in + /// the editing copy other apps read. + #[test] + fn a_saved_ai_file_names_the_views_active_artboard() { + let (mut app, written) = app(); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.sync_views(); + let opts = fileio::LoadOptions { native: false, ..Default::default() }; + for artboard in [2, 0] { + if let Some(v) = app.view_mut() { + v.artboard = artboard; + } + save(&mut app, SaveMode::Copy, &json!({"path": "Art.ai"}), false).unwrap(); + let bytes = written.borrow_mut().pop().map(|(_, b)| b).unwrap(); + let l = fileio::load_with("Art.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + let active = l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0); + assert_eq!(active, artboard); + } + } + + /// A `.ai` file a command exports names the view's active artboard as its active one: an + /// index past the last artboard names the last, as the Artboards panel shows it. + #[test] + fn an_exported_ai_file_names_the_views_active_artboard() { + let (mut app, _) = app(); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.sync_views(); + let opts = fileio::LoadOptions { native: false, ..Default::default() }; + for (artboard, active) in [(1, 1), (99, 2)] { + if let Some(v) = app.view_mut() { + v.artboard = artboard; + } + let out = app.run("document.serialize", json!({"format": "ai"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap(); + let l = fileio::load_with("Art.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert_eq!(l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)), Some(active), "{artboard}"); + } + } + fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec { let v = app.session.execute(cmd, &p).unwrap(); vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 3392e7e43..3d81aff54 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -171,14 +171,27 @@ pub(super) fn target_path_keeping( pick_path(app, &FilePick { name, folder, filters }, keeps) } -/// Keep the active document's saved view current before a save (native files reopen at it). +/// Keep the active document's saved view current before a save (native files reopen at it), and +/// its active artboard ([`remember_active_artboard`]). pub fn remember_view(app: &mut VectorcraftApp) { + remember_active_artboard(app); let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; if let Some(st) = app.session.active_mut() { st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation }); } } +/// Keep the active document's active artboard current (a `.ai` file saved or exported names it +/// as its active one): the view's, an index past the last artboard being the last one as in the +/// Artboards panel; `None` without a view. +pub fn remember_active_artboard(app: &mut VectorcraftApp) { + let index = app.view().map(|v| v.artboard); + if let Some(st) = app.session.active_mut() { + let last = st.doc.artboards.len().saturating_sub(1); + st.active_artboard = index.and_then(|i| st.doc.artboards.get(i.min(last))).map(|a| a.id); + } +} + /// A path typed in a save panel: kept when its extension names one of the panel's file types (a /// save format, or a template format for Save as Template, `mode`). An extension that names /// another format (`card.png`, or `card.ait` in Save As) is replaced with `f`'s, and `f`'s is diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 971dfa016..22c13bdbb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -62,7 +62,9 @@ private editing data. VectorCraft: Editing Data for Other Apps) every page's `PieceInfo` also carries an editing copy of the art in `AIPrivateData` streams, in the form VectorCraft's `.ai` reader reads: layers and sublayers, groups, paths with their fills and strokes (a compound path as one path of all its subpaths), process, spot and global colors, swatches and color groups, guides, - hidden and locked objects, object names, every artboard with its name, the transparency of objects and layers + hidden and locked objects, object names, every artboard with its name, the active artboard (`CropAreaActive`: the + view's active artboard when the app saves or exports the file, else the one the document opened on, else the first; + the reader opens the file on it), the transparency of objects and layers (opacity, blending mode, isolation, knockout and knockout shape), objects' opacity masks, and clipping groups and clipping layers clipped by a path or compound path, linear and radial gradients on fills and strokes (with their stops' opacity, midpoints and spot colors, elliptical radials and focal points) and gradient swatches, and diff --git a/docs/mcp.md b/docs/mcp.md index ee359ccdf..0b418bbc7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4684,6 +4684,11 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files (default true) with false write blank pages around the native document (smaller; other apps show empty pages). - `.ai` only: `compress` (default true) is the PDF option `compression.compressText`. +- The editing copy of a `.ai` or `.ait` file (`editingData`) names its active artboard (`CropAreaActive`): the active + artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the + active artboard of the `.ai` file it was opened from), else the first. A `.ai` file read from its editing copy + opens on that artboard. VectorCraft opens a `.ai` file it saved from the native document the file carries, on the + first artboard. File → Save As asks for these in the save options dialog (VectorCraft Options) after the save panel, and Save a Copy does too for a `.ai` file; Save, Save a Copy to a native file and Save as Template reuse the options as last saved (or Use Compression). From 4333c5803386d03e553f1e6d17c190f42866bd61 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:22:46 -0500 Subject: [PATCH 10/92] Opening a slow file shows a progress window with Cancel and Continue in Background (#5930) A file that takes longer than 0.4 s to open shows a modal progress window with its name, a moving bar (reading reports no steps), how many more files wait, Cancel (file.cancelOpen: the file doesn't open) and Continue in Background (file.openInBackground). It never opens over another dialog or progress window. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/export_progress.rs | 33 ++++++++++ crates/ui-egui/src/menus/run.rs | 2 + crates/ui-egui/src/menus/state.rs | 2 + crates/ui-egui/src/menus/ui_commands.rs | 12 ++++ crates/ui-egui/src/opening.rs | 87 ++++++++++++++++++++++++- crates/ui-egui/src/opening/tests.rs | 87 +++++++++++++++++++++++++ docs/gaps.md | 5 +- docs/ui-parity.md | 4 +- 9 files changed, 227 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 4698856df..faffd897b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -550,6 +550,7 @@ impl VectorcraftApp { export_progress::show(self, &ctx); plugin_runs::show(self, &ctx); trace_runs::show(self, &ctx); + opening::show(self, &ctx); palette::show(self, &ctx); if self.custom_titlebar { titlebar::resize_zones(ui); diff --git a/crates/ui-egui/src/export_progress.rs b/crates/ui-egui/src/export_progress.rs index 1854ad734..02d1ab7ff 100644 --- a/crates/ui-egui/src/export_progress.rs +++ b/crates/ui-egui/src/export_progress.rs @@ -135,6 +135,39 @@ pub fn busy(ui: &mut egui::Ui, what: &str) { }); } +/// Seconds the moving segment of an [`indeterminate`] bar takes to cross the track once. +const SWEEP: f64 = 1.6; + +/// The share of the track an [`indeterminate`] bar's segment covers. +const SEGMENT: f32 = 0.3; + +/// Where the segment of an [`indeterminate`] bar is at `time` seconds: its left and right ends as +/// shares of the track, clipped to it (it enters at the left and leaves at the right). +pub fn sweep_at(time: f64) -> (f32, f32) { + let phase = if time.is_finite() { (time / SWEEP).rem_euclid(1.0) as f32 } else { 0.0 }; + let left = phase * (1.0 + SEGMENT) - SEGMENT; + (left.max(0.0), (left + SEGMENT).min(1.0)) +} + +/// Work whose progress isn't known, in a [`window`]: `what` it does, in the UI language, over a +/// bar whose segment keeps sweeping across it while the window shows. +pub fn indeterminate(ui: &mut egui::Ui, what: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(what).color(t.text_dim)); + ui.add_space(4.0); + let (rect, response) = ui.allocate_exact_size(egui::vec2(360.0, 6.0), egui::Sense::hover()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ProgressIndicator, true, what)); + let radius = egui::CornerRadius::same(3); + ui.painter().rect_filled(rect, radius, ui.visuals().extreme_bg_color); + let (from, to) = sweep_at(ui.input(|i| i.time)); + if to > from { + let x = |share: f32| rect.left() + rect.width() * share; + let segment = egui::Rect::from_min_max(egui::pos2(x(from), rect.top()), egui::pos2(x(to), rect.bottom())); + ui.painter().rect_filled(segment, radius, t.accent); + } + ui.ctx().request_repaint(); +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 47ecead54..a912e1df2 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -125,6 +125,8 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::reveal(app), "file.cancelExport" => Ok(io::cancel_exports(app)), "file.exportInBackground" => Ok(io::hide_exports(app)), + "file.cancelOpen" => Ok(crate::opening::cancel_all(app)), + "file.openInBackground" => Ok(crate::opening::hide_all(app)), id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { Some(path) => io::open_reporting(app, &path), None => Err("no such recent file".into()), diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 78553cc7b..aa533b30b 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -202,6 +202,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()), "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), "file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()), + "file.cancelOpen" => !app.opening.pending.is_empty(), + "file.openInBackground" => crate::opening::any_shown(app), "plugin.cancelRuns" => crate::plugin_runs::any_running(app), "plugin.hideRuns" => crate::plugin_runs::any_shown(app), "imageTrace.cancelRuns" => crate::trace_runs::any_running(app), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 52f8f8473..39ebedf24 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1187,6 +1187,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} close the export progress dialog; the exports go on, shown in the status bar → {hidden: n}", ), + ( + "file.cancelOpen", + "Cancel Opening", + "", + "{} stop opening every file being read in the background (its progress window's Cancel): the files don't open → {cancelled: n}", + ), + ( + "file.openInBackground", + "Continue in Background", + "", + "{} close the progress window of the files being opened; they go on being read, shown in the status bar → {hidden: n}", + ), ( "ui.dxfOptionsDialog", "DXF Options Dialog", diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index fb2949dd4..9191509ae 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -1,8 +1,11 @@ //! Opening documents off the UI thread (desktop): a file the user asks to open (File › Open, Open //! Recent, the Home screen, files handed to the app) is decoded on a thread of its own, which also //! reads its linked files and lays out its type, while the status bar shows it, and opens in a new tab -//! once it is ready, as it would at once: the same notes, recent files and errors. On wasm (no -//! threads) and in unit tests it opens at once ([`available`]). +//! once it is ready, as it would at once: the same notes, recent files and errors. A file that takes +//! longer than [`DELAY`] shows a progress window ([`show`]): its name, a bar that moves while it is +//! read (the reader counts no steps), Cancel (also Esc, `file.cancelOpen`: the file doesn't open) +//! and Continue in Background (`file.openInBackground`: it goes on, shown in the status bar). On +//! wasm (no threads) and in unit tests it opens at once ([`available`]). use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; @@ -12,9 +15,13 @@ use vectorcraft_engine::cmd::fileio::{self, ReadFile}; use vectorcraft_engine::file_access::ScopeHandle; use crate::VectorcraftApp; +use crate::export_progress::{self, Pressed}; + +pub use crate::export_progress::DELAY; /// A file being read. pub struct Pending { + id: u64, /// Where it is. pub path: String, /// What the status bar shows ("Opening Poster.vectorcraft"). @@ -23,6 +30,8 @@ pub struct Pending { /// The automation roots in force where the open started: the document is installed under /// them too (its links are read as it opens). scope: ScopeHandle, + /// Continue in Background: the progress window no longer shows it. + pub hidden: bool, } /// The files being read. @@ -32,6 +41,7 @@ pub struct Opening { /// Open documents asked for again while the active one couldn't change: their tabs come to /// the front once it can ([`may_switch`]), each in the file access scope it was asked in. pub activate: Vec<(String, ScopeHandle)>, + next_id: u64, } /// Is a long operation running that should show the busy cursor and status spinner (background @@ -81,7 +91,9 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result Value { + let cancelled = std::mem::take(&mut app.opening.pending); + for p in &cancelled { + // The status bar no longer says it is opening. + if app.ui.status == format!("{}…", p.label) { + app.ui.status.clear(); + } + } + json!({ "cancelled": cancelled.len() }) +} + +/// The progress window's Continue in Background: the files go on being read, shown in the status +/// bar only → `{hidden: n}`. +pub fn hide_all(app: &mut VectorcraftApp) -> Value { + let mut n = 0; + for p in app.opening.pending.iter_mut().filter(|p| !p.hidden) { + p.hidden = true; + n += 1; + } + json!({ "hidden": n }) +} + +/// Is a file being read that the progress window shows? +pub fn any_shown(app: &VectorcraftApp) -> bool { + app.opening.pending.iter().any(|p| !p.hidden) +} + +/// The progress window of the first file being read it shows, once that has been read for +/// [`DELAY`]: a quick open doesn't flash it. Not while a dialog or another progress window (an +/// export, a heavy plug-in filter, an Image Trace) is open: never two modals at once. +pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + let shown: Vec<&Pending> = app.opening.pending.iter().filter(|p| !p.hidden).collect(); + let Some(first) = shown.first() else { return }; + let now = ctx.input(|i| i.time); + let since = ctx.data_mut(|m| { + let seen = m.get_temp_mut_or_insert_with(egui::Id::unique("opening-seen"), || (first.id, now)); + if seen.0 != first.id { + *seen = (first.id, now); + } + seen.1 + }); + let exporting = app.background.jobs.iter().any(|j| j.progress.is_some() && !j.hidden && !j.cancelled()); + let other = exporting || crate::plugin_runs::any_shown(app) || crate::trace_runs::any_shown(app); + if now - since < DELAY || app.ui.dialog.is_some() || other { + return; + } + let heading = format!("{}…", crate::i18n::msg(&first.label)); + let more = shown.len() - 1; + let t = crate::theme::Tokens::get(ctx); + let pressed = export_progress::window(ctx, "opening-progress", &heading, |ui| { + export_progress::indeterminate(ui, tl!("Reading the file…")); + if more > 0 { + ui.add_space(6.0); + let queued = crate::i18n::tn(more as u64, "{n} more file waiting", "{n} more files waiting"); + ui.label(egui::RichText::new(queued).size(11.5).color(t.text_dim)); + } + }); + let id = match pressed { + Some(Pressed::Cancel) => "file.cancelOpen", + Some(Pressed::Hide) => "file.openInBackground", + None => return, + }; + if let Err(e) = app.run(id, json!({})) { + app.status(e); + } +} + /// Open a file read off the UI thread, as [`crate::io::open_reporting`] opens one read at once. fn finish(app: &mut VectorcraftApp, path: &str, r: Result) { let opened = r.and_then(|file| fileio::open_read(&mut app.session, file).map_err(|e| e.to_string())); diff --git a/crates/ui-egui/src/opening/tests.rs b/crates/ui-egui/src/opening/tests.rs index 40e2e6009..1f5a6cbd1 100644 --- a/crates/ui-egui/src/opening/tests.rs +++ b/crates/ui-egui/src/opening/tests.rs @@ -363,3 +363,90 @@ fn a_file_that_opens_and_asks_holds_back_the_next_one_ready() { assert_eq!(app.session.doc().unwrap().path.as_deref(), Some("/art/Asks.vectorcraft"), "the dialog's document stays active"); assert_eq!(app.opening.pending.len(), 1, "the next file waits"); } + +/// Draws the opening progress window at `time` seconds → the texts it drew. +fn progress(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> String { + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + let mut texts = String::new(); + for shape in out.shapes { + if let egui::epaint::Shape::Text(t) = shape.shape { + texts.push_str(t.galley.text()); + texts.push('\n'); + } + } + texts +} + +/// A document's bytes, `title` naming it. +fn bytes(title: &str) -> Vec { + let mut d = vectorcraft_doc::Document::new(120.0, 80.0); + d.title = title.into(); + vectorcraft_format::save(&d, false) +} + +/// #5930: a file that takes a while to open shows a progress window with its name and a moving +/// bar, after [`DELAY`] so a quick open doesn't flash it; a second file says it waits, and the +/// window waits for a dialog to close. +#[test] +fn a_file_that_takes_a_while_to_open_shows_a_progress_window() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + assert_eq!(progress(&mut app, &ctx, 0.0), "", "nothing opening"); + start(&mut app, "/art/Big.vectorcraft", bytes("Big")).unwrap(); + assert_eq!(progress(&mut app, &ctx, 1.0), "", "not at once"); + progress(&mut app, &ctx, 1.0 + DELAY + 0.05); // a new window measures itself unseen first + let texts = progress(&mut app, &ctx, 1.0 + DELAY + 0.1); + for want in ["Opening Big.vectorcraft…", "Reading the file…", "Cancel", "Continue in Background"] { + assert!(texts.contains(want), "{want} in {texts}"); + } + assert!(!texts.contains("waiting"), "{texts}"); + start(&mut app, "/art/Next.vectorcraft", bytes("Next")).unwrap(); + let texts = progress(&mut app, &ctx, 2.0); + assert!(texts.contains("Opening Big.vectorcraft…") && texts.contains("1 more file waiting"), "{texts}"); + app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({}))); + assert_eq!(progress(&mut app, &ctx, 3.0), "", "never two modals at once"); + app.ui.dialog = None; + // Continue in Background: the window closes, the files still open. + let before = app.session.documents().len(); + assert!(crate::menus::enabled(&app, "file.openInBackground")); + assert_eq!(app.run("file.openInBackground", json!({})).unwrap()["hidden"], 2); + assert_eq!(progress(&mut app, &ctx, 4.0), ""); + assert!(!crate::menus::enabled(&app, "file.openInBackground")); + assert!(long_operation_busy(&app), "the status bar still shows them"); + open_ready(&mut app); + assert_eq!(app.session.documents().len(), before + 2); +} + +/// #5930: the progress window's Cancel (`file.cancelOpen`): the file doesn't open, even once it +/// has been read, and the status bar no longer says it is opening. +#[test] +fn cancelling_an_open_opens_nothing() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + assert!(!crate::menus::enabled(&app, "file.cancelOpen")); + let before = app.session.documents().len(); + start(&mut app, "/art/Big.vectorcraft", bytes("Big")).unwrap(); + assert!(crate::menus::enabled(&app, "file.cancelOpen")); + assert_eq!(app.run("file.cancelOpen", json!({})).unwrap()["cancelled"], 1); + assert!(app.opening.pending.is_empty() && !long_operation_busy(&app)); + assert!(!app.ui.status.starts_with("Opening"), "{}", app.ui.status); + std::thread::sleep(std::time::Duration::from_millis(50)); + poll(&mut app); + assert_eq!(app.session.documents().len(), before); + assert_ne!(app.ui.recent_files.first().map(String::as_str), Some("/art/Big.vectorcraft")); +} + +/// The moving bar's segment enters at the left, crosses the track and leaves at the right, and a +/// time that isn't finite puts it at the start. +#[test] +fn the_indeterminate_bar_sweeps_across_its_track() { + use crate::export_progress::sweep_at; + assert_eq!(sweep_at(0.0), (0.0, 0.0)); + let (from, to) = sweep_at(0.8); + assert!(from > 0.0 && to < 1.0 && to > from, "{from} {to}"); + let (from, to) = sweep_at(1.55); + assert!(from > 0.6 && to == 1.0, "{from} {to}"); + assert_eq!(sweep_at(f64::NAN), (0.0, 0.0)); + assert_eq!(sweep_at(-0.8), sweep_at(0.8)); +} diff --git a/docs/gaps.md b/docs/gaps.md index adb50d60f..7ff56d5f9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,9 +1,10 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +| 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -437,7 +438,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall Estimate unchanged. - **Done (2026-10-21, #394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Arcs, spirals, flares and the Pen's new paths are not turned yet. Estimate unchanged. - **Done (2026-10-21, #394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Arcs, spirals, flares and the Pen's new paths are not turned yet. Estimate unchanged. **2026-10-24:** General › Corner Radius is the Rounded Rectangle tool's radius: the tool takes the preference when chosen and when it changes while the tool is active (or lent a selection tool for a drag), and the radius ↑/↓ set while drawing one, or a Tool Options value, becomes the preference, clamped to its 0–1296 pt range (). Estimate unchanged. -- **Done (2026-10-17):** Background Export shows its progress in a dialog with Cancel and Continue in Background (#2564), on the shared cancel token (#2546); a cancelled export writes no file. Save As, Export for Screens and Export Selection don't show it yet. Estimate unchanged. +- **Done (2026-10-17):** Background Export shows its progress in a dialog with Cancel and Continue in Background (#2564), on the shared cancel token (#2546); a cancelled export writes no file. Save As, Export for Screens and Export Selection don't show it yet. Estimate unchanged. **2026-11-06:** opening a file that takes longer than 0.4 s shows a progress window too, with a moving bar, Cancel and Continue in Background (part of #5930); Save and Place still show only the status bar's spinner. Estimate unchanged. - **Done:** quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the controls with Threshold and Advanced (Paths, Corners, Noise), as the public documentation describes (). Other context-specific Properties gaps remain. - **Done (2026-10-31, #394):** Units › East Asian Type is read, and Q and Ha are units. 1 Q and 1 Ha are each a quarter of a millimetre (0.709 pt), the Japanese typesetting units: the [Illustrator Scripting Guide's diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 35762b438..5ba97bacb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,9 +1,10 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid, #3835; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (opening a slow file shows a progress window with Cancel and Continue in Background, part of #5930; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence +| 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -397,6 +398,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Gradient panel: delete a stop | done | Delete Stop at the right end of the ramp deletes the selected stop, and a stop dragged down off the ramp is deleted on release, never below two stops, one undo step each; [Gradient panel options](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/gradient-tool-and-panel.html); `panels::gradient` test | | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset | done | `UiState::workspace_changes` | +| Opening a file: after 0.4 s a progress window with the file name, a moving bar (reading counts no steps), how many more files wait, Cancel (Esc: the file doesn't open) and Continue in Background; never over another dialog or progress window | done | #5930; `opening::show`, `file.cancelOpen`, `file.openInBackground`. Save, Place and Close still show only the status bar's spinner (Place and Save run off the UI thread; Close is immediate) | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library::`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | From 5216632e6b02ad34fe2c0ea15e467cef91f2a050 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 13:37:09 -0800 Subject: [PATCH 11/92] .ai save: exports to a path and timestamped copies name the active artboard A .ai file that File > Export As, its dialog or a control request writes to a path now names the view's active artboard, as document.export and document.serialize do. The UI export records the view's artboard and takes its document from fileio::export_doc, which applies DocState::active_artboard to the export's copy; document.export and document.serialize use the same function. The app now records the view's active artboard before every command, including the commands the UI handles itself, so a .ai copy from Save a Timestamped Copy or Export All Open Documents names it too. Before, these files named the artboard the document opened on, or the first. The open document is unchanged. --- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/export.rs | 17 +++++++++---- crates/ui-egui/src/addons/document/tests.rs | 27 ++++++++++++++++++++- crates/ui-egui/src/app/commands.rs | 6 ++--- crates/ui-egui/src/io.rs | 27 +++++++++++++++++++++ crates/ui-egui/src/io/export.rs | 8 +++--- 6 files changed, 74 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 7c828221b..0c888d13c 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -57,9 +57,9 @@ use serde_json::{Value, json}; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; -pub use export::export_source; pub(crate) use export::with_artboard_fills; pub(crate) use export::{export_crop, isolated}; +pub use export::{export_doc, export_source}; pub(crate) use load::check_not_lossy_overwrite; use load::err; pub(crate) use load::import_svg; diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 77adaeac9..13564614b 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -72,6 +72,17 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) } +/// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait` +/// file names the view's active artboard ([`DocState::active_artboard`]) as its active one, as a +/// save does. The open document is unchanged. +pub fn export_doc<'a>(st: &'a DocState, f: &Format, p: &Value) -> Result> { + let mut doc = export_source(st, p)?; + if let Some(id) = super::save::moved_active_artboard(st, &doc, f) { + doc.to_mut().opened_artboard = Some(id); + } + Ok(doc) +} + /// The document an export of `st` writes: the whole document, or with `selectedOnly` just the /// selected objects, each in its layers and groups (a clipping group keeps its clipping path). pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result> { @@ -190,11 +201,7 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C return Ok((Cow::Owned(doc), display_doc_title)); } let st = s.doc()?; - let mut doc = export_source(st, p)?; - // A `.ai` export names the view's active artboard, as a `.ai` save does. - if let Some(id) = super::save::moved_active_artboard(st, &doc, f) { - doc.to_mut().opened_artboard = Some(id); - } + let doc = export_doc(st, f, p)?; let display_doc_title = super::has_own_title(&doc); Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title)) } diff --git a/crates/ui-egui/src/addons/document/tests.rs b/crates/ui-egui/src/addons/document/tests.rs index 69cb75091..7ef678308 100644 --- a/crates/ui-egui/src/addons/document/tests.rs +++ b/crates/ui-egui/src/addons/document/tests.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use serde_json::json; use vectorcraft_engine::Session; -use super::SAVE_COPY; +use super::{EXPORT_ALL, SAVE_COPY}; use crate::{VectorcraftApp, menus}; /// A new, empty folder of its own for test `name`. @@ -46,6 +46,31 @@ fn save_a_timestamped_copy_writes_beside_the_document() { let _ = std::fs::remove_dir_all(&dir); } +/// A `.ai` timestamped copy, and a `.ai` copy Export All Open Documents writes, name the view's +/// active artboard as their active one. +#[test] +fn ai_copies_name_the_views_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + app.sync_views(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let opts = vectorcraft_engine::cmd::fileio::LoadOptions { native: false, ..Default::default() }; + for (i, (cmd, artboard)) in [(SAVE_COPY, 2), (EXPORT_ALL, 1)].into_iter().enumerate() { + let dir = folder(&format!("active-artboard-{i}")); + if let Some(v) = app.view_mut() { + v.artboard = artboard; + } + app.run(cmd, json!({"folder": dir, "format": "ai"})).unwrap(); + let names = files(&dir); + assert_eq!(names.len(), 1, "{cmd}: {names:?}"); + let bytes = std::fs::read(dir.join(&names[0])).unwrap(); + let l = vectorcraft_engine::cmd::fileio::load_with("a.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{cmd}: {:?}", l.warnings); + assert_eq!(l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)), Some(artboard), "{cmd}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[test] fn an_unsaved_document_asks_for_a_folder() { let dir = folder("picked"); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index a21411587..d8ef1bff8 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -93,6 +93,9 @@ impl VectorcraftApp { if cfg!(debug_assertions) { menus::check_params(&self.session, id, ¶ms)?; } + // A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names + // the view's active artboard. + io::remember_active_artboard(self); let artboards = self.artboard_tool_state(); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { self.follow_artboard_tool(artboards); @@ -173,9 +176,6 @@ impl VectorcraftApp { return r; } } - // A `.ai` file a command writes (a save, an export, a batch's save) names the view's - // active artboard. - io::remember_active_artboard(self); // Native files carry the view they reopen at. if fileio::SaveMode::of(id).is_some() { io::remember_view(self); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d394d8e6c..d1e34e38b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -207,6 +207,33 @@ mod tests { } } + /// A `.ai` file exported to a path (File › Export As, its dialog, a control request) names the + /// view's active artboard as its active one, and the open document is unchanged. + #[test] + fn an_ai_file_exported_to_a_path_names_the_views_active_artboard() { + let (mut app, written) = app(); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + app.sync_views(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let opts = fileio::LoadOptions { native: false, ..Default::default() }; + let active = |app: &VectorcraftApp| { + let bytes = written.borrow_mut().pop().map(|(_, b)| b).unwrap(); + let l = fileio::load_with("Art.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert_eq!(app.session.doc().unwrap().doc.opened_artboard, None, "the document is unchanged"); + l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)) + }; + for artboard in [2, 1] { + if let Some(v) = app.view_mut() { + v.artboard = artboard; + } + app.run("file.exportAs", json!({"format": "ai", "path": "Art.ai"})).unwrap(); + assert_eq!(active(&app), Some(artboard), "File › Export As"); + export(&mut app, Some("ai"), Some("Art.ai".into()), &json!({})).unwrap(); + assert_eq!(active(&app), Some(artboard), "the Export As dialog"); + } + } + fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec { let v = app.session.execute(cmd, &p).unwrap(); vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index f32323151..1b438adf9 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -1,6 +1,6 @@ use super::save::{ - ask_before_losing, export_path, is_web, report_saved, report_write_error, saved_status, target_path, target_path_keeping, view_file, - write_encoded, write_named, write_reporting, + ask_before_losing, export_path, is_web, remember_active_artboard, report_saved, report_write_error, saved_status, target_path, + target_path_keeping, view_file, write_encoded, write_named, write_reporting, }; use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; @@ -33,8 +33,10 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option st.doc.clone(), std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), }; From 5ce8ce486e9d4d3ac9fa207aefab1321fb8b1d0f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 14:09:16 -0800 Subject: [PATCH 12/92] .ai save: Export All names each document's own active artboard Export All Open Documents read the active artboard recorded for each document, which was kept current only for the active one. A document whose artboard changed in its view while another tab was active wrote that stale artboard as the file's active one. Before each command the app now records every open document's active artboard from that document's own view. --- crates/ui-egui/src/addons/document/tests.rs | 33 +++++++++++++++++++++ crates/ui-egui/src/io/save.rs | 19 +++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/addons/document/tests.rs b/crates/ui-egui/src/addons/document/tests.rs index 7ef678308..6e637c718 100644 --- a/crates/ui-egui/src/addons/document/tests.rs +++ b/crates/ui-egui/src/addons/document/tests.rs @@ -71,6 +71,39 @@ fn ai_copies_name_the_views_active_artboard() { } } +/// Export All Open Documents as `.ai` names each document's own active artboard, also for a +/// document whose artboard changed in its view after the last command and that is not active. +#[test] +fn export_all_names_each_documents_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + for name in ["first", "second"] { + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3, "name": name})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + } + app.sync_views(); + // Choosing a tab and an artboard runs no command. + assert!(app.session.set_active(0)); + if let Some(v) = app.view_mut() { + v.artboard = 2; + } + assert!(app.session.set_active(1)); + if let Some(v) = app.view_mut() { + v.artboard = 1; + } + let dir = folder("export-all-active-artboards"); + let r = app.run(EXPORT_ALL, json!({"folder": dir, "format": "ai"})).unwrap(); + let opts = vectorcraft_engine::cmd::fileio::LoadOptions { native: false, ..Default::default() }; + let written = r["files"].as_array().unwrap(); + assert_eq!(written.len(), 2, "{r}"); + for (f, artboard) in written.iter().zip([2, 1]) { + let bytes = std::fs::read(f["path"].as_str().unwrap()).unwrap(); + let l = vectorcraft_engine::cmd::fileio::load_with("a.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert_eq!(l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)), Some(artboard), "{}", f["title"]); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn an_unsaved_document_asks_for_a_folder() { let dir = folder("picked"); diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 3d81aff54..98e52a2c3 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -172,7 +172,7 @@ pub(super) fn target_path_keeping( } /// Keep the active document's saved view current before a save (native files reopen at it), and -/// its active artboard ([`remember_active_artboard`]). +/// each document's active artboard ([`remember_active_artboard`]). pub fn remember_view(app: &mut VectorcraftApp) { remember_active_artboard(app); let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; @@ -181,14 +181,17 @@ pub fn remember_view(app: &mut VectorcraftApp) { } } -/// Keep the active document's active artboard current (a `.ai` file saved or exported names it -/// as its active one): the view's, an index past the last artboard being the last one as in the -/// Artboards panel; `None` without a view. +/// Keep every open document's active artboard current (a `.ai` file saved or exported names it as +/// its active one, also in Export All Open Documents): its own view's, an index past the last +/// artboard being the last one as in the Artboards panel; `None` without a view. pub fn remember_active_artboard(app: &mut VectorcraftApp) { - let index = app.view().map(|v| v.artboard); - if let Some(st) = app.session.active_mut() { - let last = st.doc.artboards.len().saturating_sub(1); - st.active_artboard = index.and_then(|i| st.doc.artboards.get(i.min(last))).map(|a| a.id); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + for uid in uids { + let index = app.view_uids.iter().position(|u| *u == uid).and_then(|k| app.views.get(k)).map(|v| v.artboard); + if let Some(st) = app.session.document_mut(uid) { + let last = st.doc.artboards.len().saturating_sub(1); + st.active_artboard = index.and_then(|i| st.doc.artboards.get(i.min(last))).map(|a| a.id); + } } } From 92943095b858c018579cb971212fa461efde407f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 06:17:27 -0800 Subject: [PATCH 13/92] =?UTF-8?q?.ai=20import:=20the=20other=20Effect=20?= =?UTF-8?q?=E2=80=BA=20Pathfinder=20operations=20open=20as=20live=20effect?= =?UTF-8?q?s=20on=20a=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group whose style holds Effect › Pathfinder › Intersect, Exclude, Subtract, Minus Back, Divide, Outline, Trim, Merge, Crop, Hard Mix or Soft Mix now opens with its members editable and the effect live, as Add already did (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html). The Command numbers 1 to 10 are VectorCraft's own reader rules, learned from saved files. Soft Mix's number 11, and reading its Mix from 0.01 to 1 as a Mixing Rate of Mix times 100, are also VectorCraft's own reader rules. These still open as the drawn look: - Trap, and the effect on a path, on type, on a layer or on a fill or stroke. - Any operation whose saved Pathfinder Options (Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork), ConvertCustom, Mix or Trap settings differ from the values the reader accepts, although only Soft Mix uses Mix and none of these operations uses the Trap settings. The import fuzz test now also gives the style every option with any Mix, and checks that a live Soft Mix's rate is from 1 to 100. --- crates/engine/tests/import_fuzz.rs | 37 +++++++++++---- crates/eps/src/import/ai/style.rs | 63 ++++++++++++++++++++----- crates/eps/src/import/ai/style/tests.rs | 50 +++++++++++++++++++- crates/eps/src/import/ai/tests.rs | 62 +++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 5 files changed, 189 insertions(+), 25 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 46ca4a078..aac335bf6 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4564,11 +4564,15 @@ proptest! { /// A group with an Effect › Pathfinder style whose dictionary is hostile: any `Command`, keys of /// any type and value (missing, repeated, unknown, not finite), shown or hidden, the style cut - /// short anywhere; in editing data, an EPS and a `.ai`. A `Command` other than 0 never becomes - /// a live effect. + /// short anywhere; in editing data, an EPS and a `.ai`. The dictionary may also start with every + /// option at the value read and any `Mix`. A `Command` other than 0 to 11 never becomes a live + /// effect, and a live Soft Mix's Mixing Rate is a number from 1 to 100. #[test] fn ai_pathfinder_styles_never_panic( - command in prop_oneof![(-3i64..16).prop_map(|c| c.to_string()), arb_num()], + command in prop_oneof![(-3i64..16).prop_map(|c| c.to_string()), Just("11".to_string()), arb_num()], + defaults in any::(), + bare in any::(), + mix in prop_oneof![arb_num(), prop::sample::select(vec!["0.5", "0.3", "0.01", "0.0099", "1", "1.0001", "NaN", "1e999"]).prop_map(str::to_string)], keys in prop::collection::vec( ( prop::sample::select(vec!["Mix", "Precision", "RemovePoints", "ExtractUnpainted", "ConvertCustom", "TrapTint", "TrapThickness", "Command", "Extra"]), @@ -4581,7 +4585,19 @@ proptest! { cut in 0usize..4_000, ) { use vectorcraft_testkit::ai; + let options = if defaults { + format!( + "1 /Bool (ConvertCustom) ,\n1 /Bool (ExtractUnpainted) ,\n{mix} /Real (Mix) ,\n10 /Real (Precision) ,\n0 /Bool (RemovePoints) ,\n\ + 1 /Real (TrapAspect) ,\n0 /Bool (TrapConvertCustom) ,\n1 /Real (TrapMaxTint) ,\n0 /Bool (TrapReverse) ,\n0.25 /Real (TrapThickness) ,\n\ + 0.4 /Real (TrapTint) ,\n0.05 /Real (TrapTintTolerance) ,\n" + ) + } else { + String::new() + }; + // With every option there, the random keys are left out half the time, so the style can be read. + let keys = if defaults && bare { &keys[..0] } else { &keys[..] }; let dict: String = keys.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect(); + let dict = format!("{options}{dict}"); let styles = format!( "%AI9_BeginArtStyles\n/KnownStyle :\n(PF) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ /BasicFilter :\n(Adobe Pathfinder) 1 0 /Filter ,\n{} /Visible ,\n/Dictionary : /NotRecorded ,\n{command} /Int (Command) ,\n{dict}; /Dict ;\n /Part ,\n\ @@ -4593,12 +4609,17 @@ proptest! { let group = "0 Ae\nu\n1 0 0 1 0 0 Xa\n10 10 m\n10 60 L\n80 60 L\n80 10 L\nf\nU\n\ %_0 Ae\n%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 50 L\n%_50 50 L\n%_50 10 L\n%_f\n%_0 0 1 0 0 0 1 Xa\n%_40 20 m\n%_40 60 L\n%_80 60 L\n%_80 20 L\n%_f\n%_U\n1 (PF) XW\n"; let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", group))); - let live = command.parse::().is_ok_and(|c| c == 0.0); + let live = command.parse::().is_ok_and(|c| (0.0..=11.0).contains(&c) && c.fract() == 0.0); let check = |d: Option| { - if let Some(d) = &d - && !live - { - d.walk(|n| assert!(!n.appearance.effects.iter().any(|e| e.id.starts_with("pathfinder.")), "Command {command}: {:?}", n.appearance.effects)); + if let Some(d) = &d { + d.walk(|n| { + let effects = &n.appearance.effects; + assert!(live || !effects.iter().any(|e| e.id.starts_with("pathfinder.")), "Command {command}: {effects:?}"); + for e in effects.iter().filter(|e| e.id == "pathfinder.softMix") { + let rate = e.params.get("mixingRate").and_then(Value::as_f64); + assert!(rate.is_some_and(|r| (1.0..=100.0).contains(&r)), "Mix {mix}: {effects:?}"); + } + }); } d }; diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 0e1a52a6f..d60c7a4fd 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -111,12 +111,31 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> { id.strip_prefix("warp.") } -/// The keys of an Effect › Pathfinder › Add filter's dictionary besides `DisplayString`, each with -/// the kind and the one value read: `Command` 0 is Add, and the rest are its Pathfinder Options, -/// Soft Mix and Trap settings -/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137). -const PATHFINDER_ADD: [(&str, Kind, f64); 13] = [ - ("Command", Kind::Int, 0.0), +/// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add +/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137); +/// the numbers 1 to 10 are VectorCraft's own reader rules, learned from saved files, and 11 (Soft +/// Mix) is VectorCraft's own reader rule. Trap (12) isn't read. +const PATHFINDER_COMMANDS: [&str; 12] = [ + "pathfinder.add", + "pathfinder.intersect", + "pathfinder.exclude", + "pathfinder.subtract", + "pathfinder.minusBack", + "pathfinder.divide", + "pathfinder.outline", + "pathfinder.trim", + "pathfinder.merge", + "pathfinder.crop", + "pathfinder.hardMix", + "pathfinder.softMix", +]; + +/// The keys of an Effect › Pathfinder filter's dictionary besides `Command` and `DisplayString`, +/// each with the kind and the one value read: its Pathfinder Options, Soft Mix's mix and the Trap +/// settings. Every operation needs each of them at this value, even where the operation doesn't use +/// it, except that Soft Mix reads a `Mix` from 0.01 to 1 as its Mixing Rate (VectorCraft's own +/// reading: the rate is `Mix` times 100, within the effect's 1 to 100). +const PATHFINDER_OPTIONS: [(&str, Kind, f64); 12] = [ ("ConvertCustom", Kind::Bool, 1.0), ("ExtractUnpainted", Kind::Bool, 1.0), ("Mix", Kind::Real, 0.5), @@ -1055,18 +1074,38 @@ fn effect(f: &Filter, under: Under) -> Option { Some(Effect { id: id.into(), params, visible: f.visible }) } -/// Effect › Pathfinder › Add, when every key of [`PATHFINDER_ADD`] is there with its kind and value -/// and the only other key is the `DisplayString` text, each written once. `None` for anything else, -/// which keeps the drawn look. +/// The Effect › Pathfinder effect of [`PATHFINDER_COMMANDS`] that an integer `Command` names, when +/// every key of [`PATHFINDER_OPTIONS`] is there with its kind and value (Soft Mix's `Mix` from 0.01 +/// to 1, as its Mixing Rate in percent) and the only other key is the `DisplayString` text, each +/// written once. `None` for anything else, which keeps the drawn look. fn pathfinder_effect(f: &Filter) -> Option { - let known = f.kinds.iter().all(|(key, kind)| (key == "DisplayString" && *kind == Kind::Other) || PATHFINDER_ADD.iter().any(|(k, ..)| k == key)); + let known = f + .kinds + .iter() + .all(|(key, kind)| (key == "DisplayString" && *kind == Kind::Other) || key == "Command" || PATHFINDER_OPTIONS.iter().any(|(k, ..)| k == key)); if !known || f.repeated || !f.arrays.is_empty() || f.fill.is_some() || f.stroke.is_some() || f.gradient || f.pattern.is_some() { return None; } - if !PATHFINDER_ADD.iter().all(|(key, kind, value)| f.kinds.get(*key) == Some(kind) && f.params.get(*key) == Some(value)) { + if f.kinds.get("Command") != Some(&Kind::Int) { return None; } - Some(Effect { id: "pathfinder.add".into(), params: json!({}), visible: f.visible }) + let command = f.params.get("Command").copied().filter(|c| c.is_finite() && c.fract() == 0.0 && *c >= 0.0)?; + let id = *PATHFINDER_COMMANDS.get(usize::try_from(command as i64).ok()?)?; + let soft_mix = id == "pathfinder.softMix"; + let options = PATHFINDER_OPTIONS + .iter() + .all(|(key, kind, value)| f.kinds.get(*key) == Some(kind) && ((soft_mix && *key == "Mix") || f.params.get(*key) == Some(value))); + if !options { + return None; + } + let params = if soft_mix { + let mix = f.params.get("Mix").copied().filter(|m| (0.01..=1.0).contains(m))?; + // Rounded to six decimals: a `Mix` of 0.3 is a rate of 30. + json!({"mixingRate": (mix * 100.0 * 1e6).round() / 1e6}) + } else { + json!({}) + }; + Some(Effect { id: id.into(), params, visible: f.visible }) } /// The fill or stroke basic filter `f` paints, `Ok(None)` when it paints nothing, `Err` when it diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 4b6cc1502..bdb9a3d6e 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -516,8 +516,46 @@ fn pathfinder_add_is_read_as_its_effect() { } #[test] -fn pathfinder_commands_other_than_add_keep_the_drawn_look() { - for command in 1..=12 { +fn each_pathfinder_command_is_read_as_its_effect() { + // GUARDS: only `Command` 0 (Add) was read; a group with any other operation came in as its drawn look. + let ids = [ + (1, "pathfinder.intersect"), + (2, "pathfinder.exclude"), + (3, "pathfinder.subtract"), + (4, "pathfinder.minusBack"), + (5, "pathfinder.divide"), + (6, "pathfinder.outline"), + (7, "pathfinder.trim"), + (8, "pathfinder.merge"), + (9, "pathfinder.crop"), + (10, "pathfinder.hardMix"), + ]; + for (command, id) in ids { + let st = group_style(&[pathfinder(&pathfinder_keys(&command.to_string(), "0.5"))]); + assert!(st.plain && st.simple && st.pathfinder, "{command}: {st:?}"); + let [e] = st.effects.as_slice() else { panic!("{command}: {st:?}") }; + assert_eq!((e.id.as_str(), &e.params, e.visible), (id, &json!({}), true), "{command}"); + } +} + +#[test] +fn soft_mix_is_read_with_its_mixing_rate() { + for (mix, rate) in [("0.5", 50.0), ("0.3", 30.0), ("0.01", 1.0), ("1", 100.0)] { + let st = group_style(&[pathfinder(&pathfinder_keys("11", mix))]); + assert!(st.plain && st.simple && st.pathfinder, "{mix}: {st:?}"); + let [e] = st.effects.as_slice() else { panic!("{mix}: {st:?}") }; + assert_eq!((e.id.as_str(), &e.params), ("pathfinder.softMix", &json!({"mixingRate": rate})), "{mix}"); + } + // A rate the effect doesn't take keeps the drawn look. + for mix in ["0", "0.005", "1.5", "-0.5", "1e999"] { + let st = group_style(&[pathfinder(&pathfinder_keys("11", mix))]); + assert!(!st.plain && !st.pathfinder && st.effects.is_empty(), "{mix}: {st:?}"); + } +} + +#[test] +fn trap_and_unknown_pathfinder_commands_keep_the_drawn_look() { + for command in [12, 13, 40] { let st = group_style(&[pathfinder(&pathfinder_keys(&command.to_string(), "0.5"))]); assert!(!st.plain && !st.pathfinder && st.effects.is_empty(), "{command}: {st:?}"); } @@ -623,7 +661,15 @@ fn pathfinder_settings_this_does_not_read_keep_the_drawn_look() { ("unpainted art kept", add.replace("1 /Bool (ExtractUnpainted)", "0 /Bool (ExtractUnpainted)")), ("other trap tint", add.replace("0.4 /Real (TrapTint)", "0.25 /Real (TrapTint)")), ("Mix on Add", add.replace("0.5 /Real (Mix)", "0.3 /Real (Mix)")), + ("Mix on Intersect", pathfinder_keys("1", "0.3")), + ("Mix on Hard Mix", pathfinder_keys("10", "0.3")), ("Mix as a bool", add.replace("0.5 /Real (Mix)", "1 /Bool (Mix)")), + ("Soft Mix with Mix as an int", pathfinder_keys("11", "0.5").replace("0.5 /Real (Mix)", "1 /Int (Mix)")), + ("Soft Mix without Mix", pathfinder_keys("11", "0.5").replace("0.5 /Real (Mix) ,\n", "")), + ("Soft Mix with other Precision", pathfinder_keys("11", "0.3").replace("10 /Real (Precision)", "5 /Real (Precision)")), + ("Intersect with other Precision", pathfinder_keys("1", "0.5").replace("10 /Real (Precision)", "5 /Real (Precision)")), + ("Divide keeping unpainted art", pathfinder_keys("5", "0.5").replace("1 /Bool (ExtractUnpainted)", "0 /Bool (ExtractUnpainted)")), + ("Command twice, Intersect then Add", add.replace("0 /Int (Command)", "1 /Int (Command) ,\n0 /Int (Command)")), ]; for (case, keys) in cases { let st = group_style(&[pathfinder(&keys)]); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c883719e3..27609b10b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5153,11 +5153,69 @@ fn a_group_with_a_pathfinder_effect_keeps_its_members_and_the_effect() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +#[test] +fn each_pathfinder_operation_on_a_group_draws_as_vectorcraft_draws_it() { + // GUARDS: only Add (`Command` 0) was read; the other operations came in as the drawn look's one path. + let ops = [ + (1, "pathfinder.intersect"), + (2, "pathfinder.exclude"), + (3, "pathfinder.subtract"), + (4, "pathfinder.minusBack"), + (5, "pathfinder.divide"), + (6, "pathfinder.outline"), + (7, "pathfinder.trim"), + (8, "pathfinder.merge"), + (9, "pathfinder.crop"), + (10, "pathfinder.hardMix"), + (11, "pathfinder.softMix"), + ]; + for (command, id) in ops { + let mix = if command == 11 { 0.3 } else { 0.5 }; + let r = read_ok(&format!("{}{}", pathfinder_styles(command, mix), layer(&pathfinder_group()))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{command}: {a:#?}"); + let members = a[0].children().cloned().unwrap_or_default(); + assert_eq!(members.len(), 2, "{command}: the squares, not the look: {a:#?}"); + let [e] = a[0].appearance.effects.as_slice() else { panic!("{command}: {:?}", a[0].appearance) }; + assert_eq!((e.id.as_str(), e.visible), (id, true), "{command}"); + let params = if command == 11 { serde_json::json!({"mixingRate": 30.0}) } else { serde_json::json!({}) }; + assert_eq!(e.params, params, "{command}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{command}: {:?}", r.warnings); + // The same squares in a group made in VectorCraft, with the effect of that operation. + let square = |m: &Arc| { + let bounds = m.geometric_bounds().unwrap(); + Arc::new(Node::path(m.id, vectorcraft_geom::shapes::rectangle(bounds), vectorcraft_doc::Appearance::basic(fill(m), Paint::None, 0.0))) + }; + let mut own = Node::group(a[0].id, members.iter().map(square).collect()); + own.appearance.effects.push(vectorcraft_doc::Effect { id: id.into(), params: params.clone(), visible: true }); + // Each result's box and paints; Outline's lines start where each square's path starts, so + // the results are compared in a sorted order. + let faces = |g: &Node| -> Vec { + let mut faces: Vec = vectorcraft_effects::pathfinder_children(g, None) + .unwrap() + .iter() + .map(|n| { + let b = n.geometric_bounds().unwrap_or_default(); + let round = |v: f64| (v * 1000.0).round() / 1000.0; + let paints = (n.appearance.fill_paint().color(), n.appearance.stroke_paint().color()); + format!("{:?} {paints:?}", Rect::new(round(b.x0), round(b.y0), round(b.x1), round(b.y1))) + }) + .collect(); + faces.sort(); + faces + }; + let (opened, made) = (faces(&a[0]), faces(&own)); + assert!(!made.is_empty(), "{command}"); + assert_eq!(opened, made, "{command}"); + } +} + #[test] fn a_pathfinder_style_this_does_not_read_keeps_the_drawn_look() { - // Another command (Intersect, Trap), and a Precision other than the one read. + // Trap, and a Precision other than the one read. let other = pathfinder_styles(0, 0.5).replace("10 /Real (Precision)", "5 /Real (Precision)"); - for styles in [pathfinder_styles(1, 0.5), pathfinder_styles(12, 0.5), other] { + let intersect = pathfinder_styles(1, 0.5).replace("10 /Real (Precision)", "5 /Real (Precision)"); + for styles in [pathfinder_styles(12, 0.5), other, intersect] { let r = read_ok(&format!("{styles}{}", layer(&pathfinder_group()))); let a = art(&r.doc); assert_eq!(a.len(), 1, "{a:#?}"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b7484c59..41fae3ee3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1214,7 +1214,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`). In a `.ai` or EPS opened from its editing data, where a layer's or group's drawn look (an effect can be attached to a group or a layer, [Vector and raster effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/vector-and-raster-effects.html)) draws a copy of art with type, the import puts the page's type that lies over the copy's other art into the copy's group, instead of giving it to other text objects by painting order. Type beside the copy's art, and every copy's type in a file past the import's text-object limits, still goes by painting order. Without a page, such a copy has no type, and no warning counts it as left out. How the page's type is matched with a copy is VectorCraft's own rule (`crates/eps/src/import/tests_native.rs`). - A `.ai` opened from its editing data keeps Effect › Pathfinder on a group with no fills or strokes of its own as a live effect, with the group's members editable: Add, shown or hidden, in its order among the group's effects. The other operations, Pathfinder Options values other than the ones the reader accepts, and the effect on a path, on type, or on a fill or stroke open as the drawn look (`crates/eps/src/import/ai/style.rs`, `pathfinder_effect`). + A `.ai` opened from its editing data keeps Effect › Pathfinder on a group with no fills or strokes of its own as a live effect, with the group's members editable ([Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html)): Add, Intersect, Exclude, Subtract, Minus Back, Divide, Outline, Trim, Merge, Crop, Hard Mix and Soft Mix (with its Mixing Rate), shown or hidden, in its order among the group's effects. Which saved `Command` number names each operation other than Add, and reading Soft Mix's `Mix` from 0.01 to 1 as a Mixing Rate of `Mix` times 100, are VectorCraft's own reader rules. Trap, and the effect on a path, on type, on a layer or on a fill or stroke, open as the drawn look. So does any operation whose saved Pathfinder Options (Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork), `ConvertCustom`, `Mix` or Trap settings differ from the values the reader accepts (one value for each key, and Soft Mix's `Mix` range), although only Soft Mix uses `Mix` and none of the operations read here uses the Trap settings (`crates/eps/src/import/ai/style.rs`, `pathfinder_effect`). PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9). A `.ai` or PDF whose editing data holds a readable variable library opens with its variable definitions, data-set rows, text, visibility and linked-file values, active row and lock in the Variables panel, whether its layers are read or it comes in as its PDF part; graph values and malformed entries are skipped with a note. With its layers read, the objects its text, visibility and image variables are bound to (type for a text variable, any object for a visibility variable, a linked image for an image variable, found by their XML ids) come in bound, so selecting a data set changes them; locked and hidden objects, objects in a locked or hidden layer or group and objects on a locked artboard keep their bindings, and a data set leaves the locked ones as they are. Bindings whose object didn't come in or is of another kind are skipped with a note, as are graph bindings and every binding of a file that comes in as its PDF part (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data keeps each artboard's center mark, cross hairs, video safe areas and video ruler pixel aspect ratio, and the file's Document Raster Effects Settings; the author comes from the header's `%%For` (the editing data's, else an EPS's own) when the file's File Info gives none, the header's custom colors that no swatch has come in as spot swatches, a named color the file defines takes the place of a default swatch of that name when the file has no palette, and an older file's `%%TemplateBox` places its artboard. A value of another type or out of range keeps its default (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). From 6e39148623bb51280aa0548107eae0bdf0609c4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 07:35:18 -0800 Subject: [PATCH 14/92] .ai import: Soft Mix's Command and Mix rate were seen in saved files Comments only. A saved Soft Mix holds Command 11 and a Mix of the Mixing Rate divided by 100, and a saved Mix can fall outside the 0.01 to 1 the reader takes, which keeps the drawn look. --- crates/eps/src/import/ai/style.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index d60c7a4fd..3486ceb1d 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -113,8 +113,8 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> { /// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add /// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137); -/// the numbers 1 to 10 are VectorCraft's own reader rules, learned from saved files, and 11 (Soft -/// Mix) is VectorCraft's own reader rule. Trap (12) isn't read. +/// the numbers 1 to 11 are VectorCraft's own reader rules, learned from saved files. Trap (12) isn't +/// read. const PATHFINDER_COMMANDS: [&str; 12] = [ "pathfinder.add", "pathfinder.intersect", @@ -134,7 +134,8 @@ const PATHFINDER_COMMANDS: [&str; 12] = [ /// each with the kind and the one value read: its Pathfinder Options, Soft Mix's mix and the Trap /// settings. Every operation needs each of them at this value, even where the operation doesn't use /// it, except that Soft Mix reads a `Mix` from 0.01 to 1 as its Mixing Rate (VectorCraft's own -/// reading: the rate is `Mix` times 100, within the effect's 1 to 100). +/// reader rule, learned from saved files: the rate is `Mix` times 100, within the effect's 1 to 100; +/// a saved `Mix` outside that range keeps the drawn look). const PATHFINDER_OPTIONS: [(&str, Kind, f64); 12] = [ ("ConvertCustom", Kind::Bool, 1.0), ("ExtractUnpainted", Kind::Bool, 1.0), From d16123efad6af0e05797909c7cb596da33efacc7 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:48:55 -0500 Subject: [PATCH 15/92] Legacy .ai export: a flattened image's clip follows its edge smoothly (#5264) The clipping path around a transparent image flattened for a version 7 .ai followed 2 pt cells, a visible staircase. It now follows the rendered pixels (in blocks only past 4 million), grown by one pixel so the whole anti-aliased edge is inside, through the middle of each pixel edge and simplified with Douglas-Peucker, so slopes and curves are sloped lines. --- crates/engine/src/cmd/fileio/legacy_ai.rs | 98 ++++++++++++++++++----- crates/engine/src/tests/legacy_ai.rs | 48 +++++++++++ docs/file-format-parity.md | 5 +- 3 files changed, 129 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 691c0e836..9bf7a311a 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -14,7 +14,8 @@ //! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a //! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha //! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art -//! under it, rendered from the document as it is, clipped to the area where it changes what shows; +//! under it, rendered from the document as it is, clipped to the area where it changes what shows +//! (a smooth outline of its rendered pixels, not a staircase of them); //! the art under it stays as it was. An image in a group with opacity or a blending mode is //! flattened the same way, and a pattern fill that paints a transparent image (see-through //! pixels, or opacity or a blending mode on the image, the fill, the shape or a group it is in) is @@ -253,8 +254,11 @@ const MAX_PPI: f64 = 600.0; const MIN_PPI: f64 = 72.0; /// A rendered pixel channel changes when it changes by more than this. const CHANGED: u8 = 2; -/// The clip outline follows cells of about this many points. -const CELL: f64 = 2.0; +/// The clip outline follows the rendered pixels, in blocks of them when there would be more than +/// this many: a huge image's clip would have millions of anchors. +const MAX_CELLS: usize = 4 << 20; +/// How far (in cells) the clip outline may stray from the staircase it smooths. +const SMOOTH: f64 = 0.35; /// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and /// including it, over white, cropped and clipped to the cells where drawing it changes the @@ -288,18 +292,21 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() { return Ok(None); } - // The cells where it changes anything. - let c = (CELL * scale).round().max(1.0) as usize; + // The cells where it changes anything: its pixels, or blocks of them for a huge image. + let c = if w * h <= MAX_CELLS { 1 } else { ((w * h) as f64 / MAX_CELLS as f64).sqrt().ceil() as usize }; let (cw, ch) = (w.div_ceil(c), h.div_ceil(c)); - let mut cells = vec![false; cw * ch]; + let mut changed = vec![false; cw * ch]; for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() { if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) { let (x, y) = (k % w, k / w); - if let Some(cell) = cells.get_mut(y / c * cw + x / c) { + if let Some(cell) = changed.get_mut(y / c * cw + x / c) { *cell = true; } } } + // Grown by a cell, so the clip takes in the whole anti-aliased edge: where the image changes + // nothing it is the art under it, so a clip a little too large shows nothing more. + let cells = grown(&changed, cw, ch); let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw)); let (Some(x0), Some(x1), Some(y0), Some(y1)) = (marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max()) @@ -316,12 +323,16 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels }; let png = crop.to_png().map_err(EngineError::Other)?; let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale); + // A cell corner, in cells, as a point of the page. + let (wf, hf, cf) = (w as f64, h as f64, c as f64); + let page = |(x, y): (f64, f64)| Point::new(region.x0 + (x * cf).clamp(0.0, wf) / scale, region.y0 + (y * cf).clamp(0.0, hf) / scale); let mut clip = PathData::new(vec![]); for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) { for lp in std::iter::once(&comp.outer).chain(&comp.holes) { - let pts: Vec<(i32, i32)> = corners(&lp.pts); - let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect(); - clip.subpaths.push(SubPath::new(anchors, true)); + let anchors: Vec = smoothed(&lp.pts).into_iter().map(|p| Anchor::corner(page(p))).collect(); + if anchors.len() >= 3 { + clip.subpaths.push(SubPath::new(anchors, true)); + } } } let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale); @@ -343,17 +354,64 @@ fn up_to(n: &Node, last: usize, i: &mut usize) -> Option { Some(n) } -/// A loop's corners: its points without those in the middle of a straight run. -fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> { +/// `mask` (`w × h` cells) with every cell next to a set one set too. +fn grown(mask: &[bool], w: usize, h: usize) -> Vec { + let set = |x: usize, y: usize| mask.get(y * w + x).copied().unwrap_or(false); + let mut out = vec![false; mask.len()]; + for (k, cell) in out.iter_mut().enumerate() { + let (x, y) = (k % w, k / w); + *cell = (y.saturating_sub(1)..(y + 2).min(h)).any(|y| (x.saturating_sub(1)..(x + 2).min(w)).any(|x| set(x, y))); + } + out +} + +/// A loop of cell corners (a staircase) as a smooth outline, in cells: the middle of each cell +/// edge along it, so that a run of one-cell steps becomes the sloped line it stands for, without +/// the points that lie (within [`SMOOTH`]) on a straight line between their neighbours. +fn smoothed(pts: &[(i32, i32)]) -> Vec<(f64, f64)> { + let mut mids = Vec::with_capacity(pts.len()); + for (k, &(ax, ay)) in pts.iter().enumerate() { + let Some(&(bx, by)) = pts.get(k + 1).or(pts.first()) else { break }; + let (dx, dy) = ((bx - ax).signum(), (by - ay).signum()); + let steps = (bx - ax).unsigned_abs().max((by - ay).unsigned_abs()); + for i in 0..steps as i32 { + let (x, y) = (ax + dx * i, ay + dy * i); + mids.push((f64::from(x) + 0.5 * f64::from(dx), f64::from(y) + 0.5 * f64::from(dy))); + } + } + simplify_closed(&mids, SMOOTH) +} + +/// The points of closed polyline `pts` that Douglas–Peucker keeps with tolerance `tol`. +fn simplify_closed(pts: &[(f64, f64)], tol: f64) -> Vec<(f64, f64)> { let n = pts.len(); - let get = |k: usize| pts.get(k % n.max(1)).copied(); - (0..n) - .filter_map(|k| { - let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?); - let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1); - (!straight).then_some(b) - }) - .collect() + if n < 4 { + return pts.to_vec(); + } + let d2 = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2); + let Some(&first) = pts.first() else { return vec![] }; + // Split at the first point and the point farthest from it: two open runs. + let far = (1..n).max_by(|&i, &j| d2(first, pts[i]).total_cmp(&d2(first, pts[j]))).unwrap_or(n / 2); + let mut keep = vec![false; n]; + keep[0] = true; + keep[far] = true; + let mut todo = vec![(0, far), (far, n)]; + while let Some((a, b)) = todo.pop() { + if b <= a + 1 { + continue; + } + let (p, q) = (pts[a], pts[b % n]); + let (ux, uy) = (q.0 - p.0, q.1 - p.1); + let len = ux.hypot(uy); + let dist = |r: (f64, f64)| if len > 0.0 { ((r.0 - p.0) * uy - (r.1 - p.1) * ux).abs() / len } else { d2(p, r).sqrt() }; + let Some((m, dm)) = (a + 1..b).map(|i| (i, dist(pts[i]))).max_by(|x, y| x.1.total_cmp(&y.1)) else { continue }; + if dm > tol { + keep[m] = true; + todo.push((a, m)); + todo.push((m, b)); + } + } + pts.iter().zip(&keep).filter(|(_, k)| **k).map(|(p, _)| *p).collect() } fn note(warnings: &mut Vec, w: &str) { diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index c4722c9c1..9a9aafcba 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -368,6 +368,54 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() { assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image"); } +/// A `size`×`size` PNG of a black disk of radius `r` pixels with an anti-aliased edge, clear +/// around it, 72 ppi. +fn disk(size: u32, r: f64) -> Vec { + let c = f64::from(size) / 2.0; + let img = image::RgbaImage::from_fn(size, size, |x, y| { + let d = (f64::from(x) + 0.5 - c).hypot(f64::from(y) + 0.5 - c); + image::Rgba([0, 0, 0, ((r + 0.5 - d).clamp(0.0, 1.0) * 255.0).round() as u8]) + }); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// #5264: the clip around a flattened image follows its edge smoothly: a disk's clip is a ring of +/// sloped segments close to the disk's edge, not a staircase of horizontal and vertical steps. +#[test] +fn a_flattened_images_clip_follows_its_edge_smoothly() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 180, "height": 80})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let p = json!({"name": "disk.png", "dataBase64": vectorcraft_format::base64_encode(&disk(64, 25.0)), "at": [100, 50]}); + s.execute("file.place", &p).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + let mut clips = vec![]; + d.walk(|n| { + if let (vectorcraft_doc::NodeKind::Path { clipping: true, .. }, Some(pd)) = (&n.kind, n.path_data()) { + clips.push(pd.clone()); + } + }); + assert_eq!(clips.len(), 1, "{clips:?}"); + let sub = &clips[0].subpaths; + assert_eq!(sub.len(), 1); + let pts: Vec<_> = sub[0].anchors.iter().map(|a| a.p).collect(); + assert!(pts.len() >= 16, "{pts:?}"); + // Its centre: the file's page may sit elsewhere than the artboard. + let n = pts.len() as f64; + let (cx, cy) = (pts.iter().map(|p| p.x).sum::() / n, pts.iter().map(|p| p.y).sum::() / n); + for p in &pts { + let r = (p.x - cx).hypot(p.y - cy); + assert!((24.5..=27.5).contains(&r), "an anchor {r} pt from the centre: {pts:?}"); + } + let sloped = pts.iter().zip(pts.iter().cycle().skip(1)).filter(|(a, b)| (a.x - b.x).abs() > 0.1 && (a.y - b.y).abs() > 0.1).count(); + assert!(sloped * 2 >= pts.len(), "{sloped} of {} segments are sloped: {pts:?}", pts.len()); +} + /// How many of reply `r`'s warnings say that an image couldn't be decoded, as raster exports do. fn undecoded_notes(r: &Value) -> usize { warnings(r).iter().filter(|w| *w == "an image couldn't be decoded and is missing from the picture").count() diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 22c13bdbb..15e3fb9c4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Paste: cells copied from a spreadsheet paste as a table of point type over gridlines, #5296; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (legacy `.ai` export: a flattened transparent image's clip follows its edge smoothly, #5264; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -90,7 +90,7 @@ private editing data. VectorCraft: the options dialog's artboard picker (thumbnails to check, default all) is a file of its own, `{name}-{artboard}.ai` when there are several. It has no transparency: objects are written opaque, and images with see-through pixels, opacity or a blending mode are flattened (an opaque image of themselves and the art - under them, clipped to where they show; that art stays) or omitted (Transparent Images, + under them, clipped to where they show, along a smooth outline traced at the image's own resolution; that art stays) or omitted (Transparent Images, `transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline @@ -193,6 +193,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged | | 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | From 25b42b2246e56fd1053edb1ee2c02cb8e07e698a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 05:08:53 -0800 Subject: [PATCH 16/92] Type: layout keeps each font face's shaping plans Every shaped segment built its own harfrust shaping plan, although a plan depends only on the face, direction, script, language and features. Opening a document lays out each type object once, so a document of many short objects built thousands of identical plans. A font face now keeps up to 32 plans, oldest dropped first, and shapes with the one that matches. Feature ranges still come from the shape call, so text with the same features over other ranges shares a plan. The glyphs, bounds and baselines do not change. Docs: docs/target-app-parity.md. --- crates/engine/src/tests/links.rs | 26 +++++ crates/text/src/fontdb/build.rs | 1 + crates/text/src/fontdb/face.rs | 40 +++++++ crates/text/src/shape.rs | 5 +- crates/text/src/tests.rs | 1 + crates/text/src/tests/shape_plans.rs | 149 +++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 crates/text/src/tests/shape_plans.rs diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index 7cdf43ad7..c4d9f47c8 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -502,6 +502,32 @@ fn a_file_read_for_opening_has_its_links_read_and_its_type_laid_out() { } } +/// Opening lays out each type object with the plans the objects before it left on the font's +/// face: its bounds and baselines are those of a layout in a font database that holds no plans. +#[test] +fn type_read_for_opening_has_the_layout_of_a_font_database_with_no_plans() { + let dir = Folder::new("read-for-open-plans"); + let mut s = session(); + for (i, text) in ["AVATAR", "To Wa", "123 ...", "office", "\u{645}\u{631}\u{62d}\u{628}\u{627}"].iter().enumerate() { + s.execute("text.create", &json!({"x": 20, "y": 40 + i * 20, "text": text})).unwrap(); + } + let doc_path = dir.file("type.vectorcraft"); + save(&mut s, &doc_path); + let bytes = std::fs::read(&doc_path).unwrap(); + let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, false, false).unwrap(); + let new_db = vectorcraft_text::FontDb::with_font_dirs(vec![]); + let mut seen = 0; + file.document().walk(|node| { + if let NodeKind::Text(t) = &node.kind { + let fresh = vectorcraft_text::layout(&new_db, t); + assert_eq!(t.cached_bounds, Some(fresh.bounds), "{:?}", t.plain_text()); + assert_eq!(t.cached_baselines, fresh.baselines(), "{:?}", t.plain_text()); + seen += 1; + } + }); + assert_eq!(seen, 5); +} + #[test] fn links_from_older_files_load_and_keep_their_pixels() { let dir = Folder::new("legacy"); diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index ba91aa999..40bcb1df1 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -72,6 +72,7 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt bytes, index, path, + plans: std::sync::Mutex::new(Vec::new()), ideographic_centre: std::sync::OnceLock::new(), icf_margins: std::sync::OnceLock::new(), colour_bitmaps, diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 382b96da2..bd5090df0 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -98,6 +98,9 @@ pub(super) const MAX_AXES: usize = 64; pub(super) const MAX_INSTANCES: usize = 512; +/// The most shaping plans a face keeps ([`FontFace::shape_plan`]); past it the oldest is dropped. +const MAX_PLANS: usize = 32; + /// One axis of a variable font's design space, as its `fvar` table lists it: the tag (`wght`), its /// name in the font's name table (`Weight`; the tag when it has none) and its range and default, /// in the axis's own units. @@ -184,6 +187,8 @@ pub struct FontFace { pub(super) location: Location, /// [`Self::variations`] for the shaper (advances, kerning, feature variations). pub(crate) instance: Option, + /// The shaping plans made for this face, newest last ([`Self::shape_plan`]). + pub(super) plans: std::sync::Mutex>>, /// [`Self::ideographic_centre`], read once: layout asks for it per glyph. pub(super) ideographic_centre: std::sync::OnceLock, /// [`Self::icf_margins`], read once. @@ -226,6 +231,41 @@ impl FontFace { pub(crate) fn hb(&self) -> Option> { harfrust::FontRef::from_index(self.data(), self.index).ok() } + /// The shaping plan for a buffer of `direction`, `script` and `language` shaped with + /// `features` by `shaper` (made from this face at [`Self::instance`]): the plan made earlier for + /// the same properties, else a new one, which the face keeps for later (at most [`MAX_PLANS`]). + /// A plan depends on these properties alone, so text shaped with it comes out as with a plan + /// made for it. + pub(crate) fn shape_plan( + &self, + shaper: &harfrust::Shaper<'_>, + direction: harfrust::Direction, + script: Option, + language: Option<&harfrust::Language>, + features: &[harfrust::Feature], + ) -> Arc { + let key = harfrust::ShapePlanKey::new(script, direction).language(language).instance(self.instance.as_ref()).features(features); + let kept = |plans: &[Arc]| plans.iter().rev().find(|p| key.matches(p)).cloned(); + if let Some(plan) = kept(&self.plans.lock().unwrap_or_else(|e| e.into_inner())) { + return plan; + } + // Made without the lock held: other threads keep shaping with this face meanwhile. + let plan = Arc::new(harfrust::ShapePlan::new(shaper, direction, script, language, features)); + let mut plans = self.plans.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(made) = kept(&plans) { + return made; + } + if plans.len() >= MAX_PLANS { + plans.remove(0); + } + plans.push(plan.clone()); + plan + } + /// Tests: how many shaping plans the face holds. + #[cfg(test)] + pub(crate) fn plans_kept(&self) -> usize { + self.plans.lock().unwrap_or_else(|e| e.into_inner()).len() + } /// The face's index in its font file (collections hold several). pub(crate) fn index(&self) -> u32 { self.index diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index f37c7443e..5e1ea1a09 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -1086,7 +1086,10 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe if let Some(post) = text.get(range.end..context.end) { buf.set_post_context(post); } - let gb = shaper.shape(buf, ShapeOptions::new().features(&feats)); + // A buffer whose characters name no script reads as `UNKNOWN`: its plan has none. + let script = Some(buf.script()).filter(|s| *s != harfrust::script::UNKNOWN); + let plan = face.shape_plan(&shaper, buf.direction(), script, language.as_ref(), &feats); + let gb = shaper.shape(buf, ShapeOptions::new().features(&feats).plan(Some(&plan))); gb.glyph_infos() .iter() .zip(gb.glyph_positions()) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a5d8b5063..031d35bdc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -20,6 +20,7 @@ mod ligature_carets; mod path_joins; mod ruby; mod scripts; +mod shape_plans; mod snap; mod svg_glyphs; mod sysfonts; diff --git a/crates/text/src/tests/shape_plans.rs b/crates/text/src/tests/shape_plans.rs new file mode 100644 index 000000000..0797c4a3f --- /dev/null +++ b/crates/text/src/tests/shape_plans.rs @@ -0,0 +1,149 @@ +//! The shaping plans a face keeps: text shaped with a kept plan is laid out as text shaped with a +//! plan of its own, and text in one style makes one plan however many objects hold it. + +use harfrust::{Feature, ShapeOptions, Tag, UnicodeBuffer}; +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextObject, TextRun}; + +use crate::test_fonts::{VARIABLE_FAMILY, variable_font}; +use crate::*; + +/// A database of the bundled fonts and the test variable font, whose faces hold no plans yet. +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(variable_font().expect("the test font builds")); + db +} + +fn style() -> CharStyle { + CharStyle { size: 100.0, ..CharStyle::default() } +} + +fn point(text: &str, style: CharStyle) -> TextObject { + TextObject::point(Point::ZERO, text, style) +} + +/// What a layout draws and where: every glyph (its font's id, which differs between databases, +/// aside), the bounds and the baselines. +fn drawn(l: &TextLayout) -> Vec { + let glyph = |g: &PositionedGlyph| { + format!( + "{} {} {} {} {:?} {:?} {:?} {:?} {:?} {:?} {:?}", + g.gid, + g.byte, + g.len, + g.line, + g.xf, + g.origin, + g.advance, + g.width, + g.width_start, + g.angle, + g.outline.elements() + ) + }; + let mut all: Vec = l.glyphs.iter().map(glyph).collect(); + all.push(format!("{:?} {:?}", l.bounds, l.baselines())); + all +} + +/// Text objects whose shaping differs in script, direction, language, features and face. +fn cases() -> Vec<(&'static str, TextObject)> { + let mut mixed = point("AVAV", style()); + let manual = CharStyle { kerning: Some(0.0), ..style() }; + mixed.runs = + vec![TextRun { text: "AV".into(), style: style(), inline: None }, TextRun { text: "AV".into(), style: manual.clone(), inline: None }]; + let mut vertical = point("AB\u{65e5}\u{672c}", style()); + vertical.vertical = true; + let variable = |name: &str| CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: name.into(), ..style() }; + vec![ + ("Latin", point("AVATAR office", style())), + ("other Latin in the same style", point("To Wa fi", style())), + ("digits and punctuation, no script", point("123 ... 456", style())), + ("Turkish", point("fi office", CharStyle { language: Some("tr".into()), ..style() })), + ("kerning off", point("AVAV", manual)), + ("kerning off in the second run", mixed), + ("optical kerning", point("AVAV", CharStyle { optical_kerning: true, ..style() })), + ("small capitals", point("Small Caps", CharStyle { small_caps: Some(70.0), ..style() })), + ("Arabic", point("\u{645}\u{631}\u{62d}\u{628}\u{627}", style())), + ("Hebrew", point("\u{5e9}\u{5dc}\u{5d5}\u{5dd}", style())), + ("vertical", vertical), + ("variable, Regular", point("AVA", variable("Regular"))), + ("variable, Bold", point("AVA", variable("Bold"))), + ] +} + +#[test] +fn text_shaped_with_a_kept_plan_is_laid_out_as_with_a_plan_of_its_own() { + let shared = db(); + // Each object shapes with plans the others left, in both orders. + for (_, t) in cases().iter().chain(cases().iter().rev()) { + layout(&shared, t); + } + for (name, t) in cases() { + assert_eq!(drawn(&layout(&shared, &t)), drawn(&layout(&db(), &t)), "{name}"); + } + // The cases tell plans apart: with the font's kerning off, the pair is spaced differently. + let kerned = layout(&shared, &point("AV", style())); + let unkerned = layout(&shared, &point("AV", CharStyle { kerning: Some(0.0), ..style() })); + assert_ne!(kerned.glyphs[0].advance, unkerned.glyphs[0].advance); +} + +#[test] +fn objects_in_one_style_share_one_plan() { + let db = db(); + let face = db.style_face(&style()).unwrap(); + assert_eq!(face.plans_kept(), 0); + for i in 0..100 { + layout(&db, &point(&format!("Ab{i}"), style())); + } + assert_eq!(face.plans_kept(), 1, "one plan for 100 objects"); + layout(&db, &point("Ab", CharStyle { language: Some("tr".into()), ..style() })); + assert_eq!(face.plans_kept(), 2, "another language has another plan"); +} + +#[test] +fn a_face_keeps_a_limited_number_of_plans() { + let db = db(); + let face = db.style_face(&style()).unwrap(); + let languages: Vec = ('a'..='z').flat_map(|a| ['a', 'b'].map(|b| format!("{a}{b}"))).collect(); + let text = |language: &str| point("Ab fi", CharStyle { language: Some(language.into()), ..style() }); + for language in &languages { + layout(&db, &text(language)); + } + assert!((1..languages.len()).contains(&face.plans_kept()), "{} plans kept", face.plans_kept()); + // One the face no longer holds is made again, and one it holds is used again. + for language in [&languages[0], &languages[languages.len() - 1]] { + assert_eq!(drawn(&layout(&db, &text(language))), drawn(&layout(&self::db(), &text(language))), "{language}"); + } +} + +/// A kept plan applies the ranges of the features the text is shaped with, not those of the text +/// it was made for: a plan tells only whether a feature covers all of the text. +#[test] +fn a_kept_plan_takes_the_ranges_of_the_features_it_shapes_with() { + let db = db(); + let face = db.style_face(&style()).unwrap(); + let hb = face.hb().unwrap(); + let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); + let buffer = || { + let mut buf = UnicodeBuffer::new(); + buf.push_str("AVAVAV"); + buf.guess_segment_properties(); + buf + }; + let positions = |gb: harfrust::GlyphBuffer| gb.glyph_positions().iter().map(|p| (p.x_advance, p.x_offset)).collect::>(); + let kern_off = |from: usize| [Feature::new(Tag::new(b"kern"), 0, from..6)]; + let mut seen = vec![]; + for from in [1, 3] { + let features = kern_off(from); + let buf = buffer(); + let plan = face.shape_plan(&shaper, buf.direction(), Some(buf.script()), None, &features); + let kept = positions(shaper.shape(buf, ShapeOptions::new().features(&features).plan(Some(&plan)))); + let own = positions(shaper.shape(buffer(), ShapeOptions::new().features(&features))); + assert_eq!(kept, own, "kerning off from {from}"); + seen.push(kept); + } + assert_eq!(face.plans_kept(), 1, "the two ranges share a plan"); + assert_ne!(seen[0], seen[1], "the two ranges space the text differently"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 41fae3ee3..2031e5714 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1337,6 +1337,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. 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. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way. - **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen on Windows, on the web and on Linux under X11 and XWayland, while winit 0.30 reports none on macOS or native Wayland. On Linux, see `docs/development.md` for pens under Wayland. From 77d5f8bbea1fc248e7c89d199e5ee6f044e5336f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 06:15:32 -0800 Subject: [PATCH 17/92] Type: tests compare opened documents with and without kept shaping plans A new test binary opens a document of 13 type objects with the plans a font face keeps and again while every shaped segment makes its own plan. The document, its cached bounds and baselines included, and the first frame's pixels must be the same. `test_fonts::keep_shape_plans` switches the plans off; it exists only in tests and with the `test-fonts` feature. The engine's open test now builds objects of one script and direction that differ in kerning and language, and lays each one out in a font database of its own, so a plan used for the wrong features fails its comparison. It no longer sets Arabic, which could fall back to a system font in one database and not the other. --- crates/engine/src/tests/links.rs | 22 +++-- crates/engine/tests/open_type_plans.rs | 123 +++++++++++++++++++++++++ crates/text/src/fontdb/face.rs | 4 + crates/text/src/test_fonts.rs | 16 ++++ 4 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/open_type_plans.rs diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index c4d9f47c8..a4c222090 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -503,23 +503,29 @@ fn a_file_read_for_opening_has_its_links_read_and_its_type_laid_out() { } /// Opening lays out each type object with the plans the objects before it left on the font's -/// face: its bounds and baselines are those of a layout in a font database that holds no plans. +/// face: its bounds and baselines are those of a layout in a font database of its own, which holds +/// no plans. The objects share a script and direction and differ in features and language. Only +/// bundled Latin glyphs are set, which every font database holds. #[test] fn type_read_for_opening_has_the_layout_of_a_font_database_with_no_plans() { let dir = Folder::new("read-for-open-plans"); - let mut s = session(); - for (i, text) in ["AVATAR", "To Wa", "123 ...", "office", "\u{645}\u{631}\u{62d}\u{628}\u{627}"].iter().enumerate() { - s.execute("text.create", &json!({"x": 20, "y": 40 + i * 20, "text": text})).unwrap(); + let style = vectorcraft_doc::CharStyle { size: 100.0, ..Default::default() }; + let manual = vectorcraft_doc::CharStyle { kerning: Some(0.0), ..style.clone() }; + let turkish = vectorcraft_doc::CharStyle { language: Some("tr".into()), ..style.clone() }; + let mut doc = Document::new(800.0, 600.0); + let layer = doc.layers[0].id; + for (text, style) in [("AVAV", &style), ("AVAV", &manual), ("office", &turkish), ("To Wa", &style), ("To Wa", &manual)] { + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(20.0, 120.0), text, style.clone()); + let id = doc.alloc_id(); + doc.insert(Some(layer), usize::MAX, vectorcraft_doc::Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); } let doc_path = dir.file("type.vectorcraft"); - save(&mut s, &doc_path); - let bytes = std::fs::read(&doc_path).unwrap(); + let bytes = vectorcraft_format::save_file(&doc); let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, false, false).unwrap(); - let new_db = vectorcraft_text::FontDb::with_font_dirs(vec![]); let mut seen = 0; file.document().walk(|node| { if let NodeKind::Text(t) = &node.kind { - let fresh = vectorcraft_text::layout(&new_db, t); + let fresh = vectorcraft_text::layout(&vectorcraft_text::FontDb::with_font_dirs(vec![]), t); assert_eq!(t.cached_bounds, Some(fresh.bounds), "{:?}", t.plain_text()); assert_eq!(t.cached_baselines, fresh.baselines(), "{:?}", t.plain_text()); seen += 1; diff --git a/crates/engine/tests/open_type_plans.rs b/crates/engine/tests/open_type_plans.rs new file mode 100644 index 000000000..6ee4c99e6 --- /dev/null +++ b/crates/engine/tests/open_type_plans.rs @@ -0,0 +1,123 @@ +//! A document of type opened with the shaping plans font faces keep, compared with the same file +//! opened while every shaped segment makes a plan of its own: the nodes and the first frame's +//! pixels are the same. Its own test binary: the switch for kept plans applies to the whole process. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::{Mutex, MutexGuard, Once}; + +use serde_json::Value; +use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject, TextRun}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_geom::{Affine, Point}; +use vectorcraft_render::{RenderOptions, Renderer}; +use vectorcraft_text::FontDb; +use vectorcraft_text::test_fonts::{VARIABLE_FAMILY, keep_shape_plans, variable_font}; + +const WIDTH: u32 = 900; +const HEIGHT: u32 = 1300; + +/// Taken by each test: the switch for kept plans applies to the whole process. +static SWITCH: Mutex<()> = Mutex::new(()); + +fn switch() -> MutexGuard<'static, ()> { + SWITCH.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn style() -> CharStyle { + CharStyle { size: 60.0, ..CharStyle::default() } +} + +/// A saved document of type objects whose shaping differs in script, direction, language, features +/// and variable font instance, the object in the plain style first. +fn file() -> Vec { + static ADD_FONT: Once = Once::new(); + ADD_FONT.call_once(|| { + FontDb::global().add_font(variable_font().unwrap()); + }); + let manual = CharStyle { kerning: Some(0.0), ..style() }; + let variable = |name: &str| CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: name.into(), ..style() }; + let mut mixed = TextObject::point(Point::ZERO, "AVAV", style()); + mixed.runs = vec![TextRun::new("AV", style()), TextRun::new("AV", manual.clone())]; + let mut vertical = TextObject::point(Point::ZERO, "AB\u{65e5}\u{672c}", style()); + vertical.vertical = true; + let objects = [ + TextObject::point(Point::ZERO, "AVATAR office", style()), + TextObject::point(Point::ZERO, "To Wa fi", style()), + TextObject::point(Point::ZERO, "123 ... 456", style()), + TextObject::point(Point::ZERO, "AVAV", manual), + mixed, + TextObject::point(Point::ZERO, "fi office", CharStyle { language: Some("tr".into()), ..style() }), + TextObject::point(Point::ZERO, "AVAV", CharStyle { optical_kerning: true, ..style() }), + TextObject::point(Point::ZERO, "Small Caps", CharStyle { small_caps: Some(70.0), ..style() }), + TextObject::point(Point::ZERO, "\u{645}\u{631}\u{62d}\u{628}\u{627}", style()), + TextObject::point(Point::ZERO, "\u{5e9}\u{5dc}\u{5d5}\u{5dd}", style()), + TextObject::point(Point::ZERO, "lIn lIn", variable("Regular")), + TextObject::point(Point::ZERO, "lIn lIn", variable("Bold")), + vertical, + ]; + let mut doc = Document::new(WIDTH as f64, HEIGHT as f64); + let layer = doc.layers[0].id; + let count = objects.len(); + for (i, mut t) in objects.into_iter().enumerate() { + // Rows down the page; the vertical object, last, stands in the right margin. + t.xf = Affine::translate(if i + 1 == count { (800.0, 60.0) } else { (20.0, 90.0 * (i + 1) as f64) }); + let id = doc.alloc_id(); + doc.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + } + vectorcraft_format::save_file(&doc) +} + +/// The session `bytes` opened in, as `document.open` reads a file and then opens it. +fn open(bytes: &[u8]) -> Session { + let name = "plans.vectorcraft"; + let mut s = Session::new(); + let read = fileio::read_for_open(name, bytes, Some(name.into()), &Value::Null, false, false).unwrap(); + fileio::open_read(&mut s, read).unwrap(); + s +} + +fn first_frame(doc: &Document) -> Vec { + Renderer::new().render(doc, WIDTH, HEIGHT, Affine::IDENTITY, &RenderOptions::default()).pixels +} + +#[test] +fn an_opened_document_has_the_nodes_of_one_laid_out_with_plans_of_their_own() { + let _switch = switch(); + let bytes = file(); + keep_shape_plans(false); + let own = open(&bytes); + keep_shape_plans(true); + // The second open shapes every object with plans a face kept. + open(&bytes); + let kept = open(&bytes); + let (own, kept) = (own.doc().unwrap(), kept.doc().unwrap()); + let mut laid_out = 0; + kept.doc.walk(|node| { + if let NodeKind::Text(t) = &node.kind { + assert!(t.cached_bounds.is_some(), "{:?}", t.plain_text()); + laid_out += 1; + } + }); + assert_eq!(laid_out, 13); + assert_eq!(kept.doc.layers, own.doc.layers); + assert!(*kept.doc == *own.doc, "the whole document is the same"); +} + +#[test] +fn an_opened_document_draws_the_first_frame_of_one_shaped_with_plans_of_its_own() { + let _switch = switch(); + let bytes = file(); + let opened = open(&bytes); + let doc = &opened.doc().unwrap().doc; + keep_shape_plans(false); + let own = first_frame(doc); + keep_shape_plans(true); + // Lays out each object with plans a face kept: the first frame lays type out again. + first_frame(doc); + let kept = first_frame(doc); + assert_ne!(kept, first_frame(&Document::new(WIDTH as f64, HEIGHT as f64)), "the frame shows the type"); + let differing = kept.iter().zip(&own).filter(|(a, b)| a != b).count(); + assert_eq!((kept.len(), differing), (own.len(), 0), "bytes of the frame that differ"); +} diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index bd5090df0..5ab31eeae 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -244,6 +244,10 @@ impl FontFace { language: Option<&harfrust::Language>, features: &[harfrust::Feature], ) -> Arc { + #[cfg(any(test, feature = "test-fonts"))] + if !crate::test_fonts::keeps_shape_plans() { + return Arc::new(harfrust::ShapePlan::new(shaper, direction, script, language, features)); + } let key = harfrust::ShapePlanKey::new(script, direction).language(language).instance(self.instance.as_ref()).features(features); let kept = |plans: &[Arc]| plans.iter().rev().find(|p| key.matches(p)).cloned(); if let Some(plan) = kept(&self.plans.lock().unwrap_or_else(|e| e.into_inner())) { diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index ebdd08573..69ccbfe70 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1,6 +1,8 @@ //! Fonts built in code for tests, so no font file is committed: a small variable font (#296). //! Compiled for this crate's tests and, with the `test-fonts` feature, for other crates' tests. +use std::sync::atomic::{AtomicBool, Ordering}; + use skrifa::instance::{LocationRef, Size}; use skrifa::raw::TableProvider; use skrifa::raw::tables::glyf::Glyph; @@ -10,6 +12,20 @@ use write_fonts::tables::fvar::{AxisInstanceArrays, Fvar, InstanceRecord, Variat use write_fonts::tables::gvar::{GlyphDelta, GlyphDeltas, GlyphVariations, Gvar, Tent}; use write_fonts::types::{F2Dot14, Fixed, NameId, Tag}; +/// Whether faces use and keep shaping plans ([`keep_shape_plans`]). +static KEEP_SHAPE_PLANS: AtomicBool = AtomicBool::new(true); + +/// Sets whether font faces use and keep shaping plans, for the whole process. When `keep` is false, +/// every shaped segment makes a plan of its own, so a test can compare what kept plans produce with +/// what its own plan produces. Plans a face already holds stay and are used again when it is true. +pub fn keep_shape_plans(keep: bool) { + KEEP_SHAPE_PLANS.store(keep, Ordering::SeqCst); +} + +pub(crate) fn keeps_shape_plans() -> bool { + KEEP_SHAPE_PLANS.load(Ordering::SeqCst) +} + /// The family of [`variable_font`]. pub const VARIABLE_FAMILY: &str = "Varitest Sans"; From 3d31b623a4b0aeb3a2b73326d8fcf87c611eab43 Mon Sep 17 00:00:00 2001 From: SofiaAFigueredo Date: Fri, 30 Oct 2026 16:55:39 +0000 Subject: [PATCH 18/92] UI: centre the Home screen and use the accent for the Discord button (#4684) Part of #4684 (unify the UI with PhotoCraft). The Home screen's welcome, New / Open buttons and Community block are centred, as PhotoCraft's Home screen is, and the large Discord button takes the theme's accent instead of Discord's own colour. Preset cards, Recent Files and the wording are unchanged. Still open from #4684: shortcut hints on the Home buttons, PhotoCraft's PRO and STUDIO themes, the Preferences layout and the title bar. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0113uEh3VQ6et4zDMZ9cqzkf --- crates/ui-egui/src/canvas/home.rs | 35 ++++++++++++++++++------------- crates/ui-egui/src/community.rs | 5 ++++- crates/ui-egui/src/tests/home.rs | 31 +++++++++++++++++++++++++++ docs/ui-parity.md | 3 +++ 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/canvas/home.rs b/crates/ui-egui/src/canvas/home.rs index d73a5c5bb..672f2dd3d 100644 --- a/crates/ui-egui/src/canvas/home.rs +++ b/crates/ui-egui/src/canvas/home.rs @@ -25,18 +25,21 @@ pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { /// The Home screen's column: welcome, New / Open, Recent Files, presets and community links. fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text)); - ui.add_space(4.0); - ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim)); - ui.add_space(22.0); - ui.horizontal(|ui| { - if widgets::primary_button(ui, tl!("New file")).clicked() { - app.run("file.newDialog", json!({})).ok(); - } - ui.add_space(8.0); - if widgets::secondary_button(ui, tl!("Open")).clicked() { - app.run("file.open", json!({})).ok(); - } + // The welcome and the New / Open buttons sit centred, as PhotoCraft's Home screen does (#4684). + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text)); + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim)); + ui.add_space(22.0); + ui.horizontal(|ui| { + if widgets::primary_button(ui, tl!("New file")).clicked() { + app.run("file.newDialog", json!({})).ok(); + } + ui.add_space(8.0); + if widgets::secondary_button(ui, tl!("Open")).clicked() { + app.run("file.open", json!({})).ok(); + } + }); }); recent(app, ui); ui.add_space(28.0); @@ -54,9 +57,11 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) { } }); ui.add_space(28.0); - ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text)); - ui.add_space(10.0); - crate::community::links(app, ui); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text)); + ui.add_space(10.0); + crate::community::links(app, ui); + }); } /// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and diff --git a/crates/ui-egui/src/community.rs b/crates/ui-egui/src/community.rs index 7ed5eb984..089a421ce 100644 --- a/crates/ui-egui/src/community.rs +++ b/crates/ui-egui/src/community.rs @@ -32,7 +32,10 @@ pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egu let at = egui::pos2(0.0, r.center().y - galley.size().y / 2.0); let ir = egui::Rect::from_center_size(r.left_center() + vec2(h * 0.35 + icon / 2.0, 0.0), vec2(icon, icon)); if large { - let fill = if resp.hovered() { DISCORD.gamma_multiply(0.85) } else { DISCORD }; + // The theme's accent, as the primary buttons use it, so the call to action matches the + // rest of the window (and PhotoCraft's, #4684); Discord's colour stays on the app-bar icon. + let t = Tokens::get(ui.ctx()); + let fill = if resp.hovered() { t.accent } else { t.accent_strong }; ui.painter().rect_filled(r, CornerRadius::same((h / 2.0) as u8), fill); icons::paint(ui, "message-circle", ir, Color32::WHITE); ui.painter().galley(egui::pos2(ir.right() + h * 0.2, at.y), galley, Color32::WHITE); diff --git a/crates/ui-egui/src/tests/home.rs b/crates/ui-egui/src/tests/home.rs index 96f50716d..249aa7f76 100644 --- a/crates/ui-egui/src/tests/home.rs +++ b/crates/ui-egui/src/tests/home.rs @@ -438,3 +438,34 @@ fn a_queued_reopen_keeps_the_roots_it_was_asked_in() { assert_eq!(app.session.documents().len(), 1, "the file outside the roots is not opened"); let _ = std::fs::remove_dir_all(d); } + +/// The x-centre of the first text shape whose text is `text` on a drawn Home screen, and the +/// window's centre (#4684). +fn text_centre_x(app: &mut VectorcraftApp, text: &str) -> Option<(f32, f32)> { + fn find(s: &egui::Shape, text: &str) -> Option { + match s { + egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos.x + t.galley.size().x / 2.0), + egui::Shape::Vec(v) => v.iter().find_map(|s| find(s, text)), + _ => None, + } + } + app.canvas_rect = None; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + let raw = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + let x = out.shapes.iter().find_map(|c| find(&c.shape, text))?; + Some((x, screen.center().x)) +} + +/// The Home screen centres its welcome and its community block, as PhotoCraft's does (#4684). +#[test] +fn home_welcome_and_community_are_centred() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + for text in ["Welcome to VectorCraft", "Community"] { + let (x, centre) = text_centre_x(&mut app, text).unwrap_or_else(|| panic!("{text} is drawn")); + assert!((x - centre).abs() < 2.0, "{text} is centred: {x} vs {centre}"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5ba97bacb..3bdb39930 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -39,6 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) | | 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color follows the hex and a double-click opens the Color Picker over the dialog, whose OK fills Color (#5364); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: spinner arrows beside Opacity, the offsets, Blur and Darkness (#5367, #5368); scores and estimates unchanged | @@ -434,6 +435,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po ## Look and feel +- [x] Home screen (part of #4684, shared UI with PhotoCraft): the welcome, New / Open buttons and Community block are centred, and the large Discord button takes the theme's accent instead of Discord's colour; the layout and wording of the preset cards and Recent Files are unchanged. Still open from #4684: shortcut hints on the buttons, PhotoCraft's PRO and STUDIO themes, the Preferences layout (search box, theme swatches, Apply) and the title bar. + - [x] Recolor Artwork › Assign: double-click a row's new color to pick it in the Color Picker, in the model it was picked in (part of #4028); a Lab new color is kept but can't be typed yet (the picker's Lab fields work in RGB); Lab entry, swatch libraries and the row menu remain. - [x] Recolor Artwork Edit color chips scroll within the screen, preserving the wheel and actions (#3416); 5, 20, 200 and 400 colors, resizing and UI scaling, final-chip editing and preview/history covered by egui_kittest. From 9b8b1229b6ac8d96d610b8406f5077a570cd85ed Mon Sep 17 00:00:00 2001 From: SofiaAFigueredo Date: Fri, 30 Oct 2026 17:34:14 +0000 Subject: [PATCH 19/92] UI: centre the Home New / Open buttons and links too (#4684) The first commit centred the headings but left the New / Open buttons and the community links on the left, as `horizontal` fills the width. The buttons' row is now padded by its measured width, and the Home screen's links are centred (label only, no icon; About keeps its left-aligned icon rows). The test now measures where the centred text lies and checks the buttons, the Discord button and the links. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0113uEh3VQ6et4zDMZ9cqzkf --- crates/ui-egui/src/canvas/home.rs | 6 +++- crates/ui-egui/src/community.rs | 41 +++++++++++++++++++-------- crates/ui-egui/src/tests/home.rs | 30 ++++++++++++++++---- crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/src/widgets/buttons.rs | 7 +++++ 5 files changed, 67 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/canvas/home.rs b/crates/ui-egui/src/canvas/home.rs index 672f2dd3d..cd2ac9c44 100644 --- a/crates/ui-egui/src/canvas/home.rs +++ b/crates/ui-egui/src/canvas/home.rs @@ -31,7 +31,11 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(4.0); ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim)); ui.add_space(22.0); + // `horizontal` fills the width, so the row's own width sets its left edge. + let gap = ui.spacing().item_spacing.x + 8.0; + let row = widgets::button_width(ui, tl!("New file")) + gap + widgets::button_width(ui, tl!("Open")); ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - row) / 2.0).max(0.0)); if widgets::primary_button(ui, tl!("New file")).clicked() { app.run("file.newDialog", json!({})).ok(); } @@ -60,7 +64,7 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) { ui.vertical_centered(|ui| { ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text)); ui.add_space(10.0); - crate::community::links(app, ui); + crate::community::links_centered(app, ui); }); } diff --git a/crates/ui-egui/src/community.rs b/crates/ui-egui/src/community.rs index 089a421ce..034807b8b 100644 --- a/crates/ui-egui/src/community.rs +++ b/crates/ui-egui/src/community.rs @@ -53,27 +53,44 @@ pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egu resp } -/// One link row: icon, label, opens the command's URL. -fn link(app: &mut VectorcraftApp, ui: &mut Ui, icon: &str, label: &str, cmd: &str, url: &str) { +/// One link row: icon, label, opens the command's URL. `centered` drops the icon so the label +/// alone sits in the middle of the column. +fn link(app: &mut VectorcraftApp, ui: &mut Ui, icon: &str, label: &str, cmd: &str, url: &str, centered: bool) { let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - icons::icon(ui, icon, 16.0, t.icon); - let r = ui.add(egui::Button::new(egui::RichText::new(label).color(t.accent).size(13.0)).frame(false)).on_hover_text(url); - if r.clicked() { - app.open_link(cmd); - } - }); + let button = |ui: &mut Ui| ui.add(egui::Button::new(egui::RichText::new(label).color(t.accent).size(13.0)).frame(false)).on_hover_text(url); + let r = if centered { + button(ui) + } else { + ui.horizontal(|ui| { + icons::icon(ui, icon, 16.0, t.icon); + button(ui) + }) + .inner + }; + if r.clicked() { + app.open_link(cmd); + } } /// The Discord button and the website / app page / GitHub links. pub fn links(app: &mut VectorcraftApp, ui: &mut Ui) { + links_in(app, ui, false); +} + +/// [`links`] for a centred column (the Home screen's, as PhotoCraft's, #4684): the Discord button +/// and the links, each in the middle. +pub fn links_centered(app: &mut VectorcraftApp, ui: &mut Ui) { + links_in(app, ui, true); +} + +fn links_in(app: &mut VectorcraftApp, ui: &mut Ui, centered: bool) { discord_button(app, ui, true); ui.add_space(8.0); let l = app.session.execute("help.links", &serde_json::json!({})).unwrap_or_default(); let s = |k: &str| l[k].as_str().unwrap_or("").to_string(); - link(app, ui, "globe", tl!("ArtCraft website"), "help.website", &s("website")); - link(app, ui, "external-link", tl!("VectorCraft on getartcraft.com"), "help.appPage", &s("appPage")); - link(app, ui, "git-branch", tl!("Source code on GitHub"), "help.github", &s("github")); + link(app, ui, "globe", tl!("ArtCraft website"), "help.website", &s("website"), centered); + link(app, ui, "external-link", tl!("VectorCraft on getartcraft.com"), "help.appPage", &s("appPage"), centered); + link(app, ui, "git-branch", tl!("Source code on GitHub"), "help.github", &s("github"), centered); } #[cfg(test)] diff --git a/crates/ui-egui/src/tests/home.rs b/crates/ui-egui/src/tests/home.rs index 249aa7f76..cfe150038 100644 --- a/crates/ui-egui/src/tests/home.rs +++ b/crates/ui-egui/src/tests/home.rs @@ -440,11 +440,11 @@ fn a_queued_reopen_keeps_the_roots_it_was_asked_in() { } /// The x-centre of the first text shape whose text is `text` on a drawn Home screen, and the -/// window's centre (#4684). +/// window's centre (#4684). A centred galley's `pos` is its anchor, so its rect says where it lies. fn text_centre_x(app: &mut VectorcraftApp, text: &str) -> Option<(f32, f32)> { fn find(s: &egui::Shape, text: &str) -> Option { match s { - egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos.x + t.galley.size().x / 2.0), + egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos.x + t.galley.rect.center().x), egui::Shape::Vec(v) => v.iter().find_map(|s| find(s, text)), _ => None, } @@ -460,12 +460,32 @@ fn text_centre_x(app: &mut VectorcraftApp, text: &str) -> Option<(f32, f32)> { Some((x, screen.center().x)) } -/// The Home screen centres its welcome and its community block, as PhotoCraft's does (#4684). +/// The Home screen centres its welcome, its New / Open buttons and its community block, as +/// PhotoCraft's does (#4684). #[test] fn home_welcome_and_community_are_centred() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); - for text in ["Welcome to VectorCraft", "Community"] { + for text in ["Welcome to VectorCraft", "Community", "ArtCraft website", "VectorCraft on getartcraft.com", "Source code on GitHub"] { let (x, centre) = text_centre_x(&mut app, text).unwrap_or_else(|| panic!("{text} is drawn")); - assert!((x - centre).abs() < 2.0, "{text} is centred: {x} vs {centre}"); + assert!((x - centre).abs() < 6.0, "{text} is centred: {x} vs {centre}"); } + // The Discord button is centred; its label lies right of its icon, 0.375 of the button's 36 pt + // height right of the middle. + let (x, centre) = text_centre_x(&mut app, "Join our Discord").unwrap(); + assert!((x - centre - 0.375 * 36.0).abs() < 2.0, "the Discord button is centred: label at {x} about {centre}"); + // The two buttons (a label's width plus 32, at least 72, each) lie symmetrically about the + // centre: the first one's left edge and the second one's right edge are equally far from it. + let width = |label: &str| { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut w = 0.0; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| w = crate::widgets::button_width(ui, label)); + out.textures_delta.clear(); + w + }; + let (new_x, centre) = text_centre_x(&mut app, "New file").unwrap(); + let (open_x, _) = text_centre_x(&mut app, "Open").unwrap(); + let left = new_x - width("New file") / 2.0; + let right = open_x + width("Open") / 2.0; + assert!((centre - left - (right - centre)).abs() < 6.0, "New / Open row is centred: {left}..{right} about {centre}"); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index efde0d0b1..5a6daf1fa 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -28,7 +28,7 @@ pub use buttons::{ SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button, primary_button_enabled, secondary_button, split_button, split_button_named, toggle_icon, }; -pub(crate) use buttons::{icon_button_info, primary_button_width, secondary_button_width}; +pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, secondary_button_width}; #[cfg(test)] use chips::gradient_chip_colors; pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; diff --git a/crates/ui-egui/src/widgets/buttons.rs b/crates/ui-egui/src/widgets/buttons.rs index 44ee3c477..a33f7cac8 100644 --- a/crates/ui-egui/src/widgets/buttons.rs +++ b/crates/ui-egui/src/widgets/buttons.rs @@ -101,6 +101,13 @@ fn flat_fill(ui: &Ui, rect: Rect, resp: &Response) { } } +/// The width [`primary_button`] and [`secondary_button`] give `text` (their label plus padding, at +/// least 72), for centring a row of them. +pub(crate) fn button_width(ui: &Ui, text: &str) -> f32 { + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), Color32::PLACEHOLDER); + (galley.size().x + 32.0).max(72.0) +} + /// The accent call-to-action button (dialog OK/Create). pub fn primary_button(ui: &mut Ui, text: &str) -> Response { primary_button_enabled(ui, text, true) From 6173bd404f520eb43b9278c73528613d3bce158c Mon Sep 17 00:00:00 2001 From: SofiaAFigueredo Date: Fri, 6 Nov 2026 08:19:57 -0300 Subject: [PATCH 20/92] Tests: give the Home centring test a taller window (#4684) The Community links can fall below the first scroll viewport at 1000x700 when the fonts differ, so the test measured nothing. 1000x900 keeps every link visible. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/tests/home.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tests/home.rs b/crates/ui-egui/src/tests/home.rs index cfe150038..d1c20991a 100644 --- a/crates/ui-egui/src/tests/home.rs +++ b/crates/ui-egui/src/tests/home.rs @@ -452,7 +452,7 @@ fn text_centre_x(app: &mut VectorcraftApp, text: &str) -> Option<(f32, f32)> { app.canvas_rect = None; let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0)); let raw = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); out.textures_delta.clear(); From 892c0659e5b03a1f2e81fb3d2eb882714dca9271 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:48:30 -0500 Subject: [PATCH 21/92] Transform fields: Tab moves from a field to the next field, one press each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A numeric field's label is a scrub zone laid over the label (a drag scrubs the field, a click focuses it). It was registered with `Sense::click_and_drag()`, which is focusable, so every field had a second, invisible tab stop: Tab went from X to W's label zone and only then to W. The zone is now `CLICK | DRAG`, not focusable, and the W/H Constrain Proportions link — a button between W and H in the Control bar — is no longer a tab stop either, so Tab goes from W straight to H. The preference stays reachable from Preferences › General. Test: `one_tab_moves_from_a_field_to_the_next` (Transform panel, Properties panel's Transform section and Control bar). --- crates/ui-egui/src/panels/tests_constrain.rs | 50 ++++++++++++++++++++ crates/ui-egui/src/panels/transform.rs | 7 ++- crates/ui-egui/src/scrub.rs | 5 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs index a90f2b55d..44ede9641 100644 --- a/crates/ui-egui/src/panels/tests_constrain.rs +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -296,3 +296,53 @@ fn the_link_is_a_preference_agents_can_read_and_set() { run(&mut app, "object.setBounds", json!({"height": 100, "reference": 4, "proportional": true})); assert_eq!(size(&app), (200.0, 100.0)); } + +/// One Tab moves from a field to the next field: neither a field's label (its scrub zone) nor the +/// W/H link between two fields is a tab stop of its own. In the Transform panel and the Properties +/// panel the fields run X, W, Y, H (a grid, two to a row); in the Control bar X, Y, W, H. +#[test] +fn one_tab_moves_from_a_field_to_the_next() { + // From X (the selection's centre, 60 pt) the next field is W in the panels and Y in the bar. + for (name, draw, next) in [ + ("Transform panel", transform::show as Draw, "width"), + ("Properties", properties::transform_section as Draw, "width"), + ("Control bar", crate::chrome::control_bar as Draw, "y"), + ] { + let mut app = rect_app(); + tab_then_type(&mut app, draw, "60 pt", "200"); + let st = app.session.active().unwrap(); + let b = st.doc.bounds_of(&st.selection.objects, false).unwrap(); + match next { + "width" => assert_eq!(size(&app), (200.0, 50.0), "{name}: Tab went from X to W"), + _ => assert_eq!((b.width().round(), b.y0.round()), (100.0, 175.0), "{name}: Tab went from X to Y"), + } + } + // In the Control bar the W/H link sits between W and H: Tab goes from W straight to H. + let mut app = rect_app(); + tab_then_type(&mut app, crate::chrome::control_bar, "100 pt", "200"); + assert_eq!(size(&app), (100.0, 200.0), "Control bar: Tab went from W to H"); +} + +/// Type `value` into the field of what `draw` shows that shows `field` after one Tab, and press +/// Enter. The field the Tab moved to takes the value; the one pressed keeps its own. +fn tab_then_type(app: &mut VectorcraftApp, draw: Draw, field: &str, value: &str) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 1.0; + frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, draw) + }; + frame(app, vec![]); + let shown = frame(app, vec![]); + let at = shown.iter().filter(|(s, _)| s == field).map(|(_, r)| r.center()).next().unwrap_or_else(|| panic!("no {field} field in {shown:?}")); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let tab = Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + for events in [vec![Event::PointerMoved(at), button(at, true), button(at, false)], vec![], vec![tab], vec![]] { + frame(app, events); + } + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + for events in [vec![Event::Text(value.into())], vec![enter], vec![]] { + frame(app, events); + } +} diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 042913ca0..caf69ec86 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -484,11 +484,14 @@ pub fn link_width(ui: &Ui) -> f32 { pub const CONSTRAIN_LINK_SIZE: f32 = 22.0; /// The link between W and H (Transform panel, Properties panel, Control bar): one toggle, the -/// `constrainProportions` preference, which the size fields pass on as `proportional`. +/// `constrainProportions` preference, which the size fields pass on as `proportional`. It is not a +/// tab stop, so Tab goes from W straight to H (it is the same preference as Preferences › General +/// › Constrain Width and Height Proportions for the keyboard). pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) { let on = app.session.prefs.constrain_proportions; let tip = tl!("Constrain Width and Height Proportions"); - if widgets::icon_button(ui, if on { "link" } else { "link-2-off" }, tip, on, CONSTRAIN_LINK_SIZE).clicked() { + let (_, resp) = ui.allocate_exact_size(egui::Vec2::splat(CONSTRAIN_LINK_SIZE), egui::Sense::CLICK); + if widgets::paint_icon_button(ui, resp, if on { "link" } else { "link-2-off" }, tip, on).clicked() { set_pref(app, "constrainProportions", !on); } } diff --git a/crates/ui-egui/src/scrub.rs b/crates/ui-egui/src/scrub.rs index 70ceb5060..5a0a2ff4f 100644 --- a/crates/ui-egui/src/scrub.rs +++ b/crates/ui-egui/src/scrub.rs @@ -168,10 +168,11 @@ pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option ui.interact(label.rect, link, Sense::drag()), - None => ui.interact(label.rect, field.with("scrub-label"), Sense::click_and_drag()), + None => ui.interact(label.rect, field.with("scrub-label"), Sense::CLICK | Sense::DRAG), }; drag(ui, &resp, field, text, value, decimals, label.link.is_some()).map(|v| v.max(*bounds.start()).min(*bounds.end())) } From 5ecf7e7dc0ec764b7bbde163eacb23cacff97478 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:25:38 -0500 Subject: [PATCH 22/92] Transform fields: the W/H link stays a tab stop, after the fields A review found the link lost its keyboard focus when it stopped being a tab stop. It is focusable again, and the Control bar now keeps the link's place between W and H but makes the button after the H field, so Tab still goes from W straight to H and finds the link after it (as the Transform and Properties panels already draw it). Tests: Shift+Tab goes back one field too, and a click on the Control bar's link still toggles the preference. The Blob Brush Tool Options test that expected two Tabs between the Roundness field and the tip preview now expects one, as every field does. Docs: the Tab entry in target-app-parity.md, both revision histories and the M1 milestone. --- crates/ui-egui/src/chrome/control_bar.rs | 10 ++++- crates/ui-egui/src/dialogs/tests.rs | 8 ++-- crates/ui-egui/src/panels/tests_constrain.rs | 46 +++++++++++++++++++- crates/ui-egui/src/panels/transform.rs | 20 ++++++--- docs/roadmap.md | 2 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 3 +- 7 files changed, 77 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 9afbe838e..70c6fe272 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -381,10 +381,12 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let link = crate::panels::transform::proportional(app, ui); let copy = crate::panels::transform::alt_enter_copies(app, ui); let values = [(c.x, origin.x), (c.y, origin.y), (b.rect.width(), 0.0), (b.rect.height(), 0.0)]; + // The W/H link sits between W and H, but is made after H so Tab goes from W + // straight to H and finds the link after it. + let mut link_room = None; for ((k, lbl), (v, offset)) in INLINE_FIELDS.into_iter().zip(values) { - // The W/H link sits between W and H. if k == "height" { - crate::panels::transform::constrain_link(app, ui); + link_room = Some(crate::panels::transform::constrain_link_room(ui)); } widgets::field_label(ui, inline_label(&t, lbl)); let (id, shown) = (("cb", k), Some(v - offset)); @@ -397,6 +399,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link, "copy": copy})).ok(); } } + if let Some(room) = link_room { + let id = ui.scope_id().with("cb-constrain-link"); + crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click())); + } } }); }); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index c2a7aa2b1..4421888c7 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2386,8 +2386,8 @@ fn the_flattener_and_3d_sliders_step_with_the_arrow_keys() { /// The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle /// as its value. A click in it points the Angle at the pointer and gives it the focus, its arrow /// keys then step the Angle by one degree (ten with Shift) within -180° to 180°, Shift+Tab leaves -/// it for the Roundness field and Tab comes back to it, and a drag still sets the Angle and gives -/// it the focus. +/// it for the Roundness field and Tab comes back to it (one press: the Roundness field's label is +/// not a tab stop of its own), and a drag still sets the Angle and gives it the focus. #[test] fn the_blob_brush_tip_preview_is_a_named_slider_with_arrow_keys() { use egui::Key::{ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Home, Tab}; @@ -2428,8 +2428,8 @@ fn the_blob_brush_tip_preview_is_a_named_slider_with_arrow_keys() { f.key(&mut app, Tab, M::SHIFT); f.key(&mut app, ArrowRight, M::NONE); assert_eq!(angle(&app), -179.0, "the Roundness field has the focus"); - // Tab goes from the Roundness field to the scrub area on its label, then to the preview. - f.key(&mut app, Tab, M::NONE); + // Tab goes from the Roundness field straight to the preview: a field's scrub label is not a + // tab stop of its own. f.key(&mut app, Tab, M::NONE); f.key(&mut app, ArrowRight, M::NONE); assert_eq!(angle(&app), -178.0, "Tab comes back to the preview"); diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs index 44ede9641..390438084 100644 --- a/crates/ui-egui/src/panels/tests_constrain.rs +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -321,11 +321,55 @@ fn one_tab_moves_from_a_field_to_the_next() { let mut app = rect_app(); tab_then_type(&mut app, crate::chrome::control_bar, "100 pt", "200"); assert_eq!(size(&app), (100.0, 200.0), "Control bar: Tab went from W to H"); + // Shift+Tab goes back one field, one press too: from W to X in the panels. + for (name, draw) in [("Transform panel", transform::show as Draw), ("Properties", properties::transform_section as Draw)] { + let mut app = rect_app(); + shift_tab_then_type(&mut app, draw, "100 pt", "200"); + let st = app.session.active().unwrap(); + let b = st.doc.bounds_of(&st.selection.objects, false).unwrap(); + assert_eq!((b.width().round(), b.x0.round()), (100.0, 150.0), "{name}: Shift+Tab went from W to X"); + } +} + +/// A click on the Control bar's W/H link toggles Constrain Width and Height Proportions, and the +/// link comes after the fields in the tab order, so Tab still moves from W to H. +#[test] +fn the_control_bar_link_toggles_by_click() { + let mut app = rect_app(); + assert!(!app.session.prefs.constrain_proportions); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 1.0; + frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, crate::chrome::control_bar) + }; + frame(&mut app, vec![]); + let shown = frame(&mut app, vec![]); + let at = |text: &str| shown.iter().find(|(t, _)| t == text).map(|(_, r)| *r).unwrap_or_else(|| panic!("no `{text}` in {shown:?}")); + let (w, h) = (at("100 pt"), at("50 pt")); + // The link's button fills the gap between the W and H fields. + let link = egui::pos2((w.right() + h.left()) / 2.0, w.center().y); + let button = |pressed| Event::PointerButton { pos: link, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + for events in [vec![Event::PointerMoved(link), button(true), button(false)], vec![]] { + frame(&mut app, events); + } + assert!(app.session.prefs.constrain_proportions, "the click toggled the link"); } /// Type `value` into the field of what `draw` shows that shows `field` after one Tab, and press /// Enter. The field the Tab moved to takes the value; the one pressed keeps its own. fn tab_then_type(app: &mut VectorcraftApp, draw: Draw, field: &str, value: &str) { + tab_then_type_with(app, draw, field, value, Modifiers::NONE); +} + +/// [`tab_then_type`] with Shift+Tab, which goes back one field. +fn shift_tab_then_type(app: &mut VectorcraftApp, draw: Draw, field: &str, value: &str) { + tab_then_type_with(app, draw, field, value, Modifiers::SHIFT); +} + +/// [`tab_then_type`], the Tab key held with `modifiers`. +fn tab_then_type_with(app: &mut VectorcraftApp, draw: Draw, field: &str, value: &str, modifiers: Modifiers) { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut time = 0.0; @@ -337,7 +381,7 @@ fn tab_then_type(app: &mut VectorcraftApp, draw: Draw, field: &str, value: &str) let shown = frame(app, vec![]); let at = shown.iter().filter(|(s, _)| s == field).map(|(_, r)| r.center()).next().unwrap_or_else(|| panic!("no {field} field in {shown:?}")); let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - let tab = Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let tab = Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; for events in [vec![Event::PointerMoved(at), button(at, true), button(at, false)], vec![], vec![tab], vec![]] { frame(app, events); } diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index caf69ec86..815b5c4a8 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -484,18 +484,28 @@ pub fn link_width(ui: &Ui) -> f32 { pub const CONSTRAIN_LINK_SIZE: f32 = 22.0; /// The link between W and H (Transform panel, Properties panel, Control bar): one toggle, the -/// `constrainProportions` preference, which the size fields pass on as `proportional`. It is not a -/// tab stop, so Tab goes from W straight to H (it is the same preference as Preferences › General -/// › Constrain Width and Height Proportions for the keyboard). +/// `constrainProportions` preference, which the size fields pass on as `proportional`. It is drawn +/// after the fields it sits between, so Tab reaches the fields first and the link after them. pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) { + let (_, resp) = ui.allocate_exact_size(egui::Vec2::splat(CONSTRAIN_LINK_SIZE), egui::Sense::click()); + constrain_link_at(app, ui, resp); +} + +/// [`constrain_link`]'s toggle, made over `at`: the Control bar keeps the link's place between W +/// and H but makes it after H, so Tab goes from W straight to H and finds the link after it. +pub fn constrain_link_at(app: &mut VectorcraftApp, ui: &mut Ui, at: egui::Response) { let on = app.session.prefs.constrain_proportions; let tip = tl!("Constrain Width and Height Proportions"); - let (_, resp) = ui.allocate_exact_size(egui::Vec2::splat(CONSTRAIN_LINK_SIZE), egui::Sense::CLICK); - if widgets::paint_icon_button(ui, resp, if on { "link" } else { "link-2-off" }, tip, on).clicked() { + if widgets::paint_icon_button(ui, at, if on { "link" } else { "link-2-off" }, tip, on).clicked() { set_pref(app, "constrainProportions", !on); } } +/// The room the Control bar keeps for [`constrain_link_at`] between its W and H fields. +pub fn constrain_link_room(ui: &mut Ui) -> egui::Rect { + ui.allocate_exact_size(egui::Vec2::splat(CONSTRAIN_LINK_SIZE), egui::Sense::hover()).0 +} + /// The `copy` param of a command that a field of the Transform panel, the Properties panel's /// Transform section or the Control bar runs: true when Alt+Enter (Option+Return) applied the value /// and no anchors are selected with Direct Selection. diff --git a/docs/roadmap.md b/docs/roadmap.md index f3ad64942..dd5504b0d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -117,7 +117,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | # | Milestone | Status | Est. remaining, one agent (h) | |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | -| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | +| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2031e5714..b8fe1a68f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -727,7 +727,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does. - - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. + - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. @@ -1721,6 +1721,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | | 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. | +| 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar (each field's scrub label was a second, invisible tab stop, and so was the W/H link between W and H in the Control bar, which now comes after the fields); evidence: `one_tab_moves_from_a_field_to_the_next` and `the_control_bar_link_toggles_by_click` in `crates/ui-egui/src/panels/tests_constrain.rs`; scores and estimates unchanged | | 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. | | 2026-11-04 | minor | Area type vertical alignment buttons in the Properties panel's Paragraph section and the Control panel, off for columns, rows and threaded frames (#3759); evidence added; scores unchanged. | | 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3bdb39930..a62d8588b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (opening a slow file shows a progress window with Cancel and Continue in Background, part of #5930; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; Transform fields: one Tab per field, the W/H link after them; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -45,6 +45,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: spinner arrows beside Opacity, the offsets, Blur and Darkness (#5367, #5368); scores and estimates unchanged | | 2026-11-06 | minor | Rename (`ui.rename`, no default key) opens the Layers panel's name field on the selected layer, group or object's row, as double-clicking its name does; a key can be assigned in Keyboard Shortcuts (#5324, a VectorCraft addition); scores and estimates unchanged | | 2026-11-06 | minor | Properties panel in Edit Artboards (Shift+O): a Transform section with the shared reference point, X, Y, W and H, the preset and orientation, and Move and Scale Artwork with Artboard, above the Artboards section with the name, Artboard Options and Fill (#5219); scores and estimates unchanged | +| 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar — a field's scrub label was a second, invisible tab stop, and the W/H link between W and H is now made after the fields, so it stays a tab stop after them; scores and estimates unchanged | | 2026-11-06 | minor | Scrubby labels: the Control bar's Stroke: and Opacity: and the Properties panel's Stroke, links to their panels, scrub the field after them as other labels do; a click still opens the panel (#4088); scores and estimates unchanged | | 2026-11-05 | minor | Direct Selection: Shift-click picks several points of a blend's spine and dragging one moves them all, with the key objects on them, in one undo step (#5285); scores and estimates unchanged | | 2026-11-05 | minor | Direct Selection: Cmd/Ctrl held lends the Selection tool, whose bounding box shows while the key is down over objects selected whole; its handles scale and rotate, and a Cmd drag moves the whole object with its direct-selected anchors kept (#3901); scores and estimates unchanged | From 417039f456f78ba76d492d0156d16ec11ace679f Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:27:45 -0500 Subject: [PATCH 23/92] Transform fields: the tab-order test says the link is focusable, after the fields --- crates/ui-egui/src/panels/tests_constrain.rs | 7 ++++--- docs/target-app-parity.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs index 390438084..ea9ceec27 100644 --- a/crates/ui-egui/src/panels/tests_constrain.rs +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -297,9 +297,10 @@ fn the_link_is_a_preference_agents_can_read_and_set() { assert_eq!(size(&app), (200.0, 100.0)); } -/// One Tab moves from a field to the next field: neither a field's label (its scrub zone) nor the -/// W/H link between two fields is a tab stop of its own. In the Transform panel and the Properties -/// panel the fields run X, W, Y, H (a grid, two to a row); in the Control bar X, Y, W, H. +/// One Tab moves from a field to the next field: a field's label (its scrub zone) is not a tab +/// stop of its own, and the W/H link between W and H, which is still focusable, comes after the +/// fields. In the Transform panel and the Properties panel the fields run X, W, Y, H (a grid, two +/// to a row); in the Control bar X, Y, W, H. #[test] fn one_tab_moves_from_a_field_to_the_next() { // From X (the selection's centre, 60 pt) the next field is W in the panels and Y in the bar. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b8fe1a68f..8761aad38 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1721,7 +1721,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | | 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. | -| 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar (each field's scrub label was a second, invisible tab stop, and so was the W/H link between W and H in the Control bar, which now comes after the fields); evidence: `one_tab_moves_from_a_field_to_the_next` and `the_control_bar_link_toggles_by_click` in `crates/ui-egui/src/panels/tests_constrain.rs`; scores and estimates unchanged | +| 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar (each field's scrub label was a second tab stop, invisible because only the label is drawn there, and the W/H link between W and H in the Control bar was one too, and now comes after the fields); evidence: `one_tab_moves_from_a_field_to_the_next` and `the_control_bar_link_toggles_by_click` in `crates/ui-egui/src/panels/tests_constrain.rs`; scores and estimates unchanged | | 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. | | 2026-11-04 | minor | Area type vertical alignment buttons in the Properties panel's Paragraph section and the Control panel, off for columns, rows and threaded frames (#3759); evidence added; scores unchanged. | | 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | From adbcb3a224bad5a82fb3d7a4bea8611b12d87764 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:11:35 -0500 Subject: [PATCH 24/92] Shape tools: the selection moves from its centre point and turns outside a corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shape just drawn keeps its bounding box with the Rectangle, Rounded Rectangle, Ellipse, Polygon and Star tools, but its centre point (Attributes › Show Center) did nothing and the box's handles only drew: a press anywhere started a new shape, and with the Selection tool a press on the centre of an unfilled shape started a marquee. - The Selection tool now takes a press on a selected object's centre point as the start of a move (held by that point, so it snaps and Alt copies), as Direct Selection has since #4891. A click there still makes the object the key object of a multi-selection. - The shape tools take it too, and take a press in the rotate zone just outside a bounding-box corner (the Selection tool's own zone and cursor) as the start of a rotation about the box's centre. Live Corners widgets keep the corners themselves, so a press on a corner still rounds it; every other press still draws a new shape. Escape cancels, as for the Selection tool's drags. `hit_center` moves from direct.rs to select.rs, shared by the three tools. --- crates/tools/src/cursors.toml | 5 +- crates/tools/src/direct.rs | 12 +- crates/tools/src/lib.rs | 22 ++++ crates/tools/src/select.rs | 32 +++++- crates/tools/src/select/tests.rs | 30 +++++ crates/tools/src/shape.rs | 186 +++++++++++++++++++++++++++++-- crates/tools/src/shape/tests.rs | 68 +++++++++++ docs/target-app-parity.md | 3 +- docs/ui-parity.md | 5 +- 9 files changed, 336 insertions(+), 27 deletions(-) diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 0ac3e610a..7071f24e8 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -94,6 +94,7 @@ id = "Move" group = "Selection" shown_when = [ "The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).", + "The shape tools over a selected object's centre point (Attributes › Show Center), and while dragging it.", "Reshape over a path or while dragging.", "Inside the Free Transform and Crop Image boxes.", "Puppet Warp over a pin.", @@ -376,7 +377,9 @@ look = "An arc with an arrowhead at each end. RotateToward is the same arc, turn [[cursor]] id = "RotateToward" group = "Transform and resize" -shown_when = ["Selection and Free Transform in the rotate zone just outside a bounding-box corner, and while rotating there. The Rotate, Gradient and Puppet Warp rotate cursors don't turn."] +shown_when = [ + "Selection, Free Transform and the shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star) in the rotate zone just outside a bounding-box corner, and while rotating there. The Rotate, Gradient and Puppet Warp rotate cursors don't turn.", +] hotspot = "Below the arc, in its empty middle, as for Rotate." look = "The Rotate arc drawn for the top-right corner (direction 1). VectorCraft turns it in 45° steps so it wraps whichever corner the pointer is at." diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 3c128d0a1..2b2f04c83 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -60,7 +60,7 @@ use crate::guides::{HandleSnap, Leave, PointSnap, Targets}; use crate::meshedit::{MeshEdit, grid_of}; use crate::pathtype::{self, BracketDrag, near_bracket}; use crate::rulerguide::GuideEdit; -use crate::select::{MoveSnap, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at}; +use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Debug)] @@ -530,16 +530,6 @@ fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Po pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p))) } -/// The selected object whose centre point (Attributes panel › Show Center), drawn while it is -/// selected, is within `tol` of `p`: (object, where the point is). -fn hit_center(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Point)> { - let (symbols, editable) = (cx.doc.symbol_boxes(), cx.doc.editable()); - cx.selection.objects.iter().copied().filter(|id| editable(*id) && in_scope(cx.doc, cx.isolation, *id)).find_map(|id| { - let c = cx.doc.node(id).filter(|n| n.shows_center())?.geometric_bounds_with(&symbols)?.center(); - (c.distance(p) <= tol).then_some((id, c)) - }) -} - /// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors). fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec)> { anchor_owners(cx, is_area_type).into_iter().find_map(|id| { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ee00fdbe9..c6883c3fd 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -348,6 +348,28 @@ pub(crate) mod testutil { (d, id) } + /// A live 100 × 100 rectangle at (100, 100)–(200, 200) with no paint: its centre point + /// (Attributes › Show Center) is at (150, 150), and a press in the middle of it hits no art. + pub fn doc_with_live_rect() -> (Document, NodeId) { + use vectorcraft_doc::{LiveShape, NodeKind}; + let shape = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + }; + let mut d = Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let mut n = Node::path(id, shape.to_path(), Appearance::default()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(shape)); + } + d.insert(Some(l), 0, n).unwrap(); + (d, id) + } + /// [`doc_with_rect`] plus a clip group: a filled 200 × 200 square (350, 350)–(550, 550) /// clipped to an unpainted rectangle (400, 400)–(500, 500), so the art lies over the mask's /// edges. The group, the clipping path and the art. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 4bfd6a860..fd6cc0f5c 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -469,10 +469,23 @@ pub fn selection_box(cx: &ToolContext) -> Option { /// [`selection_box`] while the canvas shows it (View › Show Bounding Box, and no anchors selected /// on their own): its handles take a press only then. A path the Pen is drawing keeps its end /// anchor selected, so Cmd/Ctrl lending the Selection tool doesn't scale it by hidden handles (#4980). -fn shown_box(cx: &ToolContext) -> Option { +/// The shape tools press it too (#5477). +pub(crate) fn shown_box(cx: &ToolContext) -> Option { (cx.show_bbox && cx.selection.anchors.is_empty()).then(|| selection_box(cx)).flatten() } +/// The selected object whose centre point (Attributes panel › Show Center), drawn while it is +/// selected, is within `tol` of `p`: (object, where the point is). A press there grabs the object +/// by that point — it moves the selection even where no art is painted (an unfilled shape) — as +/// Direct Selection's does (#4891). +pub(crate) fn hit_center(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Point)> { + let (symbols, editable) = (cx.doc.symbol_boxes(), cx.doc.editable()); + cx.selection.objects.iter().copied().filter(|id| editable(*id) && crate::direct::in_scope(cx.doc, cx.isolation, *id)).find_map(|id| { + let c = cx.doc.node(id).filter(|n| n.shows_center())?.geometric_bounds_with(&symbols)?.center(); + (c.distance(p) <= tol).then_some((id, c)) + }) +} + /// The bounds of object `id`, measured as [`selection_bounds`] measures the selection. fn object_bounds(cx: &ToolContext, id: NodeId) -> Option { with_object_bounds(cx, |bounds| bounds(id)) @@ -822,6 +835,18 @@ impl Tool for SelectionTool { self.state = State::Idle; return out; } + // 2b. A selected object's centre point (Attributes › Show Center), drawn while it + // is selected: a drag from it moves the selection, also where no art is painted + // (an unfilled shape), which a press there would otherwise take for a marquee. A + // click there is a click on that object, so it makes it the key object too. + if !m.shift + && !m.cmd + && let Some((id, center)) = hit_center(cx, p, cx.point_tol()) + { + let key = ((cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty()) && !m.alt).then_some(id); + self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect: None, key, exclude: None }; + return vec![]; + } // 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling). if m.cmd && cx.select_behind @@ -1071,6 +1096,11 @@ impl Tool for SelectionTool { if near_selected && pie::over_widget(cx, p) { return Cursor::ArrowHollow; } + // A selected object's centre point (Show Center): a press there moves it, where the art + // may not reach. + if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { + return Cursor::Move; + } if over_rotate_all(cx, p) { return Cursor::Rotate; } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 714975bb0..027bfb203 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2139,3 +2139,33 @@ fn a_double_click_on_a_symbol_instance_edits_its_symbol() { assert_eq!(double(&d, Some(l), 300.0, 300.0), vec![]); assert_eq!(double(&d, Some(l), 450.0, 50.0), vec![Action::Exec("object.exitIsolation".into(), json!({}))]); } + +/// #4891 for the Selection tool: a press on a selected object's centre point (Attributes › Show +/// Center) moves the selection, also where no art is painted (an unfilled shape), which the press +/// would otherwise take for a marquee; anywhere else a press still starts one. +#[test] +fn a_press_on_the_centre_point_moves_an_unfilled_shape() { + let (d, id) = doc_with_live_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let centre = Point::new(150.0, 150.0); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, centre, Mods::default()), Cursor::Move); + assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 160.0)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); + assert!( + matches!(a.last(), Some(Action::Preview(cmd, v)) if cmd == "object.transform" && v["matrix"] == json!([1.0, 0.0, 0.0, 1.0, 20.0, 10.0])), + "{a:?}" + ); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 160.0)), vec![Action::Commit]); + // Empty canvas away from it: a marquee, as before. + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, Point::new(400.0, 400.0), Mods::default()), Cursor::Arrow); + t.pointer(&c, &ev(PointerKind::Down, 400.0, 400.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 450.0, 450.0)); + assert!(a.is_empty(), "{a:?}"); + assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee"); +} diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index f48625a6a..b1d2a8558 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -10,15 +10,19 @@ //! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle, //! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at //! once: a drag on a live rectangle's or polygon's corner widget rounds its corners and a drag on -//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a press anywhere -//! else draws a new shape (#5477). +//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a press on the +//! selection's centre point (Show Center) moves it, and one just outside a corner of its box turns +//! it about that centre, as the Selection tool's rotate zone does; a press anywhere else draws a +//! new shape (#5477). use serde_json::{Value, json}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +use crate::bbox::{in_rotate_zone, move_delta, rotate_for_drag}; use crate::corners::{self, CornerDrag}; use crate::guides::{DrawSnap, Leave, square}; use crate::pie::{self, PieDrag}; +use crate::select::{MoveSnap, hit_center, matrix_json, shown_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// The largest corner radius the Rounded Rectangle tool draws, points: the range of General › @@ -26,6 +30,10 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool /// value stop there too. pub const MAX_CORNER_RADIUS: f64 = 1296.0; +/// How far (screen points) outside a corner of the selection's box a press with a shape tool turns +/// the selection, as the Selection tool's rotate zone does. +const ROTATE_ZONE: f64 = 18.0; + /// Does tool `id` keep the selection's bounding box and Live Shape widgets on show and let its /// corner and pie widgets be dragged (#5477)? The shape tools that draw a box: not Line Segment. pub fn shows_shape_widgets(id: &str) -> bool { @@ -38,6 +46,16 @@ enum WidgetDrag { Pie(PieDrag), } +/// A drag of the selection itself, taken by a shape tool: the centre point moves it and the zone +/// just outside a corner of its box turns it, as the Selection tool's drags do (#5477). +enum SelectionDrag { + /// Moving the selection by the centre point `hold`: `start` is where the press was, and the + /// move has begun once the pointer left the drag threshold. + Move { start: Point, hold: Point, began: bool, snap: Box }, + /// Turning the selection about `center` from where the press was. + Rotate { center: Point, start: Point }, +} + pub struct ShapeTool { id: &'static str, start: Option, @@ -59,6 +77,12 @@ pub struct ShapeTool { snap: DrawSnap, /// A widget of the selection is being dragged instead of a shape drawn. widget: Option, + /// The selection is being moved or turned instead of a shape drawn. + selection: Option, + /// The Smart Guides a move of the selection shows. + guides: Vec, + /// The measurement label of a move or a turn: where it goes and what it reads. + measure: Option<(Point, String)>, } /// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each @@ -95,6 +119,9 @@ impl ShapeTool { line_angle: 0.0, fill_line: false, widget: None, + selection: None, + guides: vec![], + measure: None, } } @@ -112,6 +139,101 @@ impl ShapeTool { shows_shape_widgets(self.id) && (corners::over_widget(cx, p, false) || pie::over_widget(cx, p)) } + /// The selection's box, while this tool shows it (the tools that draw a box, with a selection + /// and no anchors picked on their own). + fn box_of(&self, cx: &ToolContext) -> Option { + shows_shape_widgets(self.id).then(|| shown_box(cx)).flatten() + } + + /// Is `p` just outside a corner of the selection's box, where a press turns the selection about + /// its centre ([`ROTATE_ZONE`])? On the corner itself the Live Corners widget rounds it (#5477). + fn over_rotate(&self, cx: &ToolContext, p: Point) -> Option { + let bx = self.box_of(cx)?; + in_rotate_zone(bx.rect, bx.to_local(p), cx.tol(5.0), cx.tol(ROTATE_ZONE)).map(|_| bx.center()) + } + + /// Is `p` over the centre point of a selected object (Attributes › Show Center), where a press + /// moves the selection? The point shows whatever View › Show Bounding Box says, so the press + /// takes it too. + fn over_center(&self, cx: &ToolContext, p: Point) -> bool { + shows_shape_widgets(self.id) && hit_center(cx, p, cx.point_tol()).is_some() + } + + /// A press on the selection's centre point or just outside a corner of its box: the drag moves + /// or turns the selection instead of drawing a new shape (#5477). A turn begins with the press, + /// as the Selection tool's does; a move begins once the pointer leaves the drag threshold. + fn press_selection(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { + if !shows_shape_widgets(self.id) { + return None; + } + self.selection = match hit_center(cx, ev.pos, cx.point_tol()) { + Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }), + None => self.over_rotate(cx, ev.pos).map(|center| SelectionDrag::Rotate { center, start: ev.pos }), + }; + self.selection.as_ref()?; + self.snap.clear(); + self.guides.clear(); + self.measure = None; + Some(match self.selection { + Some(SelectionDrag::Rotate { .. }) => vec![Action::Begin("Rotate".into())], + _ => vec![], + }) + } + + /// The pointer and keys while the selection is moved or turned: `None` when it is not. + fn selection_pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { + let d = self.selection.as_mut()?; + let out = match (ev.kind, d) { + (PointerKind::Drag, SelectionDrag::Move { start, hold, began, snap }) => { + let mut out = vec![]; + if !*began { + if ev.pos.distance(*start) < cx.tol(3.0) { + return Some(out); + } + *began = true; + out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); + } + let shift = ev.mods.shift; + let (delta, guides) = if shift { + snap.snap_along(cx, *hold, move_delta(*start, ev.pos, true, cx.constrain_angle), ev.mods.alt) + } else { + snap.snap(cx, *hold, ev.pos - *start, ev.mods.alt) + }; + self.guides = guides; + self.measure = cx.measurement_labels.then(|| (ev.pos, cx.offset_label(delta.x, delta.y))); + out.push(Action::Preview("object.transform".into(), json!({"matrix": matrix_json(Affine::translate(delta)), "copy": ev.mods.alt}))); + out + } + (PointerKind::Drag, SelectionDrag::Rotate { center, start }) => { + let (a, deg) = rotate_for_drag(*center, *start, ev.pos, ev.mods.shift); + self.measure = cx.transform_tools_guides.then(|| (ev.pos, format!("{:.1}°", -deg))); + vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(a), "copy": false}))] + } + (PointerKind::Up, SelectionDrag::Move { began, .. }) => { + let began = *began; + self.selection = None; + self.guides.clear(); + self.measure = None; + if began { vec![Action::Commit] } else { vec![] } + } + (PointerKind::Up, SelectionDrag::Rotate { .. }) => { + self.selection = None; + self.measure = None; + vec![Action::Commit] + } + _ => vec![], + }; + Some(out) + } + + /// Escape during a drag of the selection turns it back, as the Selection tool's does. + fn cancel_selection(&mut self) -> Vec { + self.selection = None; + self.guides.clear(); + self.measure = None; + vec![Action::Cancel] + } + /// The pointer and keys while a widget is dragged: `None` when none is. fn widget_pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { let w = self.widget.as_mut()?; @@ -268,16 +390,19 @@ impl Tool for ShapeTool { self.id } fn busy(&self) -> bool { - self.start.is_some() || self.widget.is_some() + self.start.is_some() || self.widget.is_some() || self.selection.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { if let Some(out) = self.widget_pointer(cx, ev) { return out; } + if let Some(out) = self.selection_pointer(cx, ev) { + return out; + } match ev.kind { PointerKind::Move => { // Over a widget a press would drag, no Smart Guide for a new shape's start. - if self.over_widget(cx, ev.pos) { + if self.over_widget(cx, ev.pos) || self.over_center(cx, ev.pos) || self.over_rotate(cx, ev.pos).is_some() { self.snap.clear(); } else { self.snap.hover(cx, ev.pos, &[], None); @@ -289,6 +414,9 @@ impl Tool for ShapeTool { self.snap.clear(); return vec![]; } + if let Some(out) = self.press_selection(cx, ev) { + return out; + } let p = self.snap.press(cx, ev.pos, &[], None); self.start = Some(p); self.last = ev.pos; @@ -340,6 +468,10 @@ impl Tool for ShapeTool { } } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + // A drag of the selection: Escape turns it back, as with the Selection tool. + if self.selection.is_some() { + return if key == ToolKey::Escape { self.cancel_selection() } else { vec![] }; + } // A widget drag: Up and Down step a corner's kind; Escape cancels it, as with the // Selection tool. if let Some(w) = &mut self.widget { @@ -399,11 +531,19 @@ impl Tool for ShapeTool { match &self.widget { Some(WidgetDrag::Corner(c)) => c.adjusting(), Some(WidgetDrag::Pie(d)) => d.adjusting(), - None => false, + None => match &self.selection { + Some(SelectionDrag::Move { began, .. }) => *began, + Some(SelectionDrag::Rotate { .. }) => true, + None => false, + }, } } fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - matches!(self.widget, Some(WidgetDrag::Corner(_))) && matches!(key, ToolKey::Up | ToolKey::Down) + match (&self.widget, &self.selection) { + (Some(WidgetDrag::Corner(_)), _) => matches!(key, ToolKey::Up | ToolKey::Down), + (_, Some(_)) => key == ToolKey::Escape, + _ => false, + } } fn overlays(&self, cx: &ToolContext) -> Vec { match &self.widget { @@ -412,6 +552,10 @@ impl Tool for ShapeTool { None => {} } let mut o = self.snap.guides().to_vec(); + o.extend(self.guides.iter().cloned()); + if let Some((p, text)) = &self.measure { + o.push(Overlay::Measure { p: *p, text: text.clone() }); + } if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) { let p = self.last + Vec2::new(cx.tol(12.0), cx.tol(12.0)); let text = if self.id == "lineSegment" { @@ -446,12 +590,32 @@ impl Tool for ShapeTool { // Over a widget a press would drag (or dragging one), the Selection tool's pointer for it. let idle = self.start.is_none(); match &self.widget { - Some(WidgetDrag::Corner(_)) => Cursor::CornerRadius, - Some(WidgetDrag::Pie(_)) => Cursor::ArrowHollow, - None if idle && shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) => Cursor::CornerRadius, - None if idle && self.over_widget(cx, p) => Cursor::ArrowHollow, - None => Cursor::Crosshair, + Some(WidgetDrag::Corner(_)) => return Cursor::CornerRadius, + Some(WidgetDrag::Pie(_)) => return Cursor::ArrowHollow, + None => {} } + match &self.selection { + Some(SelectionDrag::Rotate { center, .. }) => return Cursor::rotate_about_in_view(*center, p, cx.view_angle()), + Some(_) => return Cursor::Arrow, + None => {} + } + // The selection's centre point moves it and the zone just outside a corner turns it; + // everywhere else a press draws a new shape. + if idle { + if self.over_center(cx, p) { + return Cursor::Move; + } + if let Some(center) = self.over_rotate(cx, p) { + return Cursor::rotate_about_in_view(center, p, cx.view_angle()); + } + if shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) { + return Cursor::CornerRadius; + } + if self.over_widget(cx, p) { + return Cursor::ArrowHollow; + } + } + Cursor::Crosshair } fn options(&self) -> Value { json!({ diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index f51cf117f..e53cea057 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -423,3 +423,71 @@ fn a_shape_tool_drags_the_selected_shapes_widgets() { assert!(matches!(a.last(), Some(Action::Preview(cmd, v)) if cmd == "object.setLiveShape" && v.get("pieEnd").is_some()), "{a:?}"); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 20.0)), vec![Action::Commit]); } + +/// A selected live 100 × 100 rectangle at (100, 100)–(200, 200) with no paint: its centre point +/// (Attributes › Show Center) is at (150, 150) and its corners at the box's. +fn unfilled_live_rect() -> (vectorcraft_doc::Document, Selection) { + let (d, id) = doc_with_live_rect(); + let mut s = Selection::default(); + s.add(id); + (d, s) +} + +/// #5477: with a shape tool the selection's centre point moves it and the zone just outside a +/// corner of its box turns it, as the Selection tool's do; anywhere else a press draws a new +/// shape. The centre is where no art is painted, so without this a press there would draw one. +#[test] +fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner() { + let (d, s) = unfilled_live_rect(); + let p = paint(); + let c = cx(&d, &s, &p); + let centre = Point::new(150.0, 150.0); + for id in ["rectangle", "roundedRectangle", "polygon", "star", "ellipse"] { + let mut t = ShapeTool::new(id); + assert_eq!(t.cursor(&c, centre, Mods::default()), Cursor::Move, "{id}"); + // No Smart Guide for a new shape's start over the centre point. + t.pointer(&c, &PointerEvent::new(PointerKind::Move, centre.x, centre.y)); + assert!(t.overlays(&c).is_empty(), "{id}"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)).is_empty(), "{id}"); + assert!(t.busy(), "{id}"); + // A drag moves the selection by the pointer's offset. + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{id}: {a:?}"); + assert!(t.transforming(), "{id}"); + assert_eq!( + a.last(), + Some(&Action::Preview("object.transform".into(), json!({"matrix": [1.0, 0.0, 0.0, 1.0, 20.0, 10.0], "copy": false}))), + "{id}" + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 160.0)), vec![Action::Commit], "{id}"); + assert!(!t.busy() && !t.transforming(), "{id}"); + } + // View › Hide Bounding Box hides the box, not the centre point: a press there still moves. + let hidden = ToolContext { show_bbox: false, ..cx(&d, &s, &p) }; + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&hidden, centre, Mods::default()), Cursor::Move); + t.pointer(&hidden, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); + let a = t.pointer(&hidden, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); + // Just outside the bottom-right corner (7.07 pt away, past the corner widget's 5): a press + // there turns the selection about its centre. + let outside = Point::new(205.0, 205.0); + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&c, outside, Mods::default()), Cursor::rotate_about(centre, outside)); + // A turn begins with the press, as the Selection tool's does (its drags preview into it). + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, outside.x, outside.y)), vec![Action::Begin("Rotate".into())]); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 205.0)); + let (want, _) = rotate_for_drag(centre, outside, Point::new(150.0, 205.0), false); + assert_eq!(a.last(), Some(&Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))), "{a:?}"); + assert!(t.transforming()); + // Escape turns it back. + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy()); + // Away from the centre point and the corners, the tool draws a new shape. + let away = Point::new(300.0, 300.0); + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&c, away, Mods::default()), Cursor::Crosshair); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, away.x, away.y)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 350.0, 340.0)); + assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8761aad38..1a9c2c2c7 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-06 · **Change:** minor (Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid, #3835; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; 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) ## Feature submission evidence @@ -1721,6 +1721,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | | 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. | +| 2026-11-06 | minor | Shape tools: a shape just drawn can be moved and turned with its tool still active — a press on the selection's centre point (Attributes › 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 its rotate cursor, as the Selection tool's do (#5477); the Selection tool takes the centre point too, so a press there moves an unfilled shape instead of starting a marquee; evidence: `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` (`crates/tools/src/shape/tests.rs`) and `a_press_on_the_centre_point_moves_an_unfilled_shape` (`select/tests.rs`); scores and estimates unchanged | | 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar (each field's scrub label was a second tab stop, invisible because only the label is drawn there, and the W/H link between W and H in the Control bar was one too, and now comes after the fields); evidence: `one_tab_moves_from_a_field_to_the_next` and `the_control_bar_link_toggles_by_click` in `crates/ui-egui/src/panels/tests_constrain.rs`; scores and estimates unchanged | | 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. | | 2026-11-04 | minor | Area type vertical alignment buttons in the Properties panel's Paragraph section and the Control panel, off for columns, rows and threaded frames (#3759); evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a62d8588b..5ee5c72dc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; Transform fields: one Tab per field, the W/H link after them; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; Transform fields: one Tab per field; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -45,6 +45,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: spinner arrows beside Opacity, the offsets, Blur and Darkness (#5367, #5368); scores and estimates unchanged | | 2026-11-06 | minor | Rename (`ui.rename`, no default key) opens the Layers panel's name field on the selected layer, group or object's row, as double-clicking its name does; a key can be assigned in Keyboard Shortcuts (#5324, a VectorCraft addition); scores and estimates unchanged | | 2026-11-06 | minor | Properties panel in Edit Artboards (Shift+O): a Transform section with the shared reference point, X, Y, W and H, the preset and orientation, and Move and Scale Artwork with Artboard, above the Artboards section with the name, Artboard Options and Fill (#5219); scores and estimates unchanged | +| 2026-11-06 | minor | Shape tools: a press on the selection's centre point (Show Center) moves the selection, also where no art is painted, and a press in the rotate zone just outside a bounding-box corner turns it about the centre, both as the Selection tool's do (#5477); the Selection tool takes its centre point too, so an unfilled shape moves instead of starting a marquee; scores and estimates unchanged | | 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar — a field's scrub label was a second, invisible tab stop, and the W/H link between W and H is now made after the fields, so it stays a tab stop after them; scores and estimates unchanged | | 2026-11-06 | minor | Scrubby labels: the Control bar's Stroke: and Opacity: and the Properties panel's Stroke, links to their panels, scrub the field after them as other labels do; a click still opens the panel (#4088); scores and estimates unchanged | | 2026-11-05 | minor | Direct Selection: Shift-click picks several points of a blend's spine and dragging one moves them all, with the key objects on them, in one undo step (#5285); scores and estimates unchanged | @@ -192,7 +193,7 @@ tools, never checked side by side) isn't captured row by row. | Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | | Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | -| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels) and a press anywhere else draws. Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Test `a_shape_tool_drags_the_selected_shapes_widgets` | +| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws. Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` | | Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)); locking or hiding the layer (or a group) around the isolated object afterwards keeps clicks and marquees off it, as outside isolation mode (#5309) | | "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not a feature of the reference app as documented; under discussion | | Coinciding anchors of overlapping objects: the selected object's anchor is taken, also under another object | done | #1508 | From 9c879301b4b7099b17cfcb709ab2f7a7f1ad06fe Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:37:11 -0500 Subject: [PATCH 25/92] Transform fields: the Artboard Control bar's link comes after its fields The Artboard tool's own Control bar drew the W/H Constrain Proportions link between W and H, so with that tool Tab stopped on it before H, as the object Control bar did before the last commit. It reserves the link's place and makes the button after the fields too, and the link now toggles with Space or Enter when it has the keyboard, as a dialog's button does. Tests: `one_tab_moves_from_a_field_to_the_next` covers the Artboard Control bar (W to H in one press), and `the_control_bar_link_toggles_by_click` Tabs from H to the link and toggles it with Enter. --- crates/ui-egui/src/panels/artboards.rs | 10 ++++++++-- crates/ui-egui/src/panels/tests_constrain.rs | 16 ++++++++++++++++ crates/ui-egui/src/panels/transform.rs | 7 +++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index deff9d872..54a90ddc7 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -100,10 +100,12 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { super::transform::reference_locator(ui, refi); let link = super::transform::proportional(app, ui); let rp = vectorcraft_geom::reference_point(r, refi); + // The W/H link sits between W and H, but is made after H so Tab goes from W straight to H and + // finds the link after it, as in the object Control bar. + let mut link_room = None; for (k, lbl, v) in [("x", "X:", rp.x - origin.x), ("y", "Y:", rp.y - origin.y), ("width", "W:", r.width()), ("height", "H:", r.height())] { - // The W/H link sits between W and H. if k == "height" { - super::transform::constrain_link(app, ui); + link_room = Some(super::transform::constrain_link_room(ui)); } widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim)); let set = if matches!(k, "width" | "height") { @@ -121,6 +123,10 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { super::transform::set_artboard(app, i, refi, xywh, link); } } + if let Some(room) = link_room { + let id = ui.scope_id().with("cb-ab-constrain-link"); + super::transform::constrain_link_at(app, ui, ui.interact(room, id, egui::Sense::click())); + } true } diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs index ea9ceec27..b5e8ed7f7 100644 --- a/crates/ui-egui/src/panels/tests_constrain.rs +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -322,6 +322,12 @@ fn one_tab_moves_from_a_field_to_the_next() { let mut app = rect_app(); tab_then_type(&mut app, crate::chrome::control_bar, "100 pt", "200"); assert_eq!(size(&app), (100.0, 200.0), "Control bar: Tab went from W to H"); + // The Artboard tool's Control bar puts the link after its fields too: Tab goes from W to H. + let mut app = rect_app(); + app.select_tool("artboard"); + tab_then_type(&mut app, crate::chrome::control_bar, "400 pt", "200"); + let r = app.session.doc().unwrap().doc.artboards[0].rect; + assert_eq!((r.width().round(), r.height().round()), (400.0, 200.0), "Artboard Control bar: Tab went from W to H"); // Shift+Tab goes back one field, one press too: from W to X in the panels. for (name, draw) in [("Transform panel", transform::show as Draw), ("Properties", properties::transform_section as Draw)] { let mut app = rect_app(); @@ -356,6 +362,16 @@ fn the_control_bar_link_toggles_by_click() { frame(&mut app, events); } assert!(app.session.prefs.constrain_proportions, "the click toggled the link"); + // Tab from H finds the link, and Space or Enter toggles it, as a dialog's button takes them. + let h = at("50 pt").center(); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let tab = Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + for events in [vec![Event::PointerMoved(h), button(h, true), button(h, false)], vec![], vec![tab], vec![], vec![enter], vec![]] { + frame(&mut app, events); + } + assert!(!app.session.prefs.constrain_proportions, "Enter on the focused link toggled it"); + assert_eq!(size(&app), (100.0, 50.0), "and the fields kept their values"); } /// Type `value` into the field of what `draw` shows that shows `field` after one Tab, and press diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 815b5c4a8..ee3ce335c 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -492,11 +492,14 @@ pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) { } /// [`constrain_link`]'s toggle, made over `at`: the Control bar keeps the link's place between W -/// and H but makes it after H, so Tab goes from W straight to H and finds the link after it. +/// and H but makes it after H, so Tab goes from W straight to H and finds the link after it. With +/// the keyboard on it, Space or Enter toggles it, as a dialog's button takes them. pub fn constrain_link_at(app: &mut VectorcraftApp, ui: &mut Ui, at: egui::Response) { let on = app.session.prefs.constrain_proportions; let tip = tl!("Constrain Width and Height Proportions"); - if widgets::paint_icon_button(ui, at, if on { "link" } else { "link-2-off" }, tip, on).clicked() { + let keyed = at.has_focus() + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Space) | i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + if widgets::paint_icon_button(ui, at, if on { "link" } else { "link-2-off" }, tip, on).clicked() || keyed { set_pref(app, "constrainProportions", !on); } } From 1c345d8e6a04136b848075ee2c87550bc31bd4b4 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:29:19 -0500 Subject: [PATCH 26/92] Shape tools: drawing another shape from the selected one's centre moves it A press on the centre point of the selected shape now moves the selection, as asked (#5477), so the Constrain Angle test's repeated draws from the same point moved the polygon drawn before them. It deselects first: nothing selected, the press draws. --- crates/engine/src/tests/constrain_new.rs | 3 +++ docs/ui-parity.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/constrain_new.rs b/crates/engine/src/tests/constrain_new.rs index 4184cb3c9..382e3af31 100644 --- a/crates/engine/src/tests/constrain_new.rs +++ b/crates/engine/src/tests/constrain_new.rs @@ -113,6 +113,9 @@ fn constrain_angle_turns_the_base_orientation_of_polygons_and_stars() { let shift = Mods { shift: true, ..Mods::default() }; let draw = |s: &mut Session, tool: &str, deg: f64| { set_angle(s, deg); + // Nothing selected: the press draws, rather than moving the shape drawn before it by the + // centre point it shows (#5477). + s.execute("select.none", &json!({})).unwrap(); s.select_tool(tool, ViewInfo::default()).unwrap(); gesture(s, shift, &[(Down, 100.0, 100.0), (Drag, 130.0, 140.0), (Drag, 130.0, 140.0), (Up, 130.0, 140.0)]); last_id(s) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5ee5c72dc..1062d89fd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -193,7 +193,7 @@ tools, never checked side by side) isn't captured row by row. | Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | | Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | -| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws. Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` | +| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws (so a second shape drawn from the first one's centre moves the first: press elsewhere or deselect first). Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` | | Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)); locking or hiding the layer (or a group) around the isolated object afterwards keeps clicks and marquees off it, as outside isolation mode (#5309) | | "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not a feature of the reference app as documented; under discussion | | Coinciding anchors of overlapping objects: the selected object's anchor is taken, also under another object | done | #1508 | From cd88b15912962ae93311dcbd1bc21bce13bc43df Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:56:24 -0500 Subject: [PATCH 27/92] Shape tools: review follow-ups (Shift, handle boundary, cursor order, feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Selection tool takes a Shift-press on a selected object's centre point too: a Shift-drag moves the selection held to 45° steps, and a Shift-click there takes the object out of the selection, as a Shift-press on the object does. - One rotate zone for the Selection tool and the shape tools (`select::rotate_zone`): a press on a bounding-box handle is not a turn, so the shape tools' zone matches the Selection tool's classification exactly. - The shape tools' hover cursor follows the press order (corner and pie widgets, then the centre point, then the rotate zone), and a move of the selection shows the Selection tool's feedback: SelectionCopy with Alt, ArrowHollow while it snaps. - Engine-level tests for the whole gesture (`crates/engine/src/tests/shapetools.rs`): repeated previews and one undo step, Alt copy, Escape then release, a turn about the centre, the corner widget still rounding, and an unfilled shape moved by the Selection tool. - docs: evidence in target-app-parity.md's Shapes bullet and the M2 milestone, and the Move cursor's entry names the copy and snap cursors. --- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/shapetools.rs | 133 ++++++++++++++++++++++++++ crates/tools/src/cursors.toml | 2 +- crates/tools/src/select.rs | 25 +++-- crates/tools/src/select/tests.rs | 30 ++++++ crates/tools/src/shape.rs | 54 ++++++----- crates/tools/src/shape/tests.rs | 12 ++- docs/roadmap.md | 1 + docs/target-app-parity.md | 2 +- 9 files changed, 225 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/tests/shapetools.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 812a6282f..862bc4b77 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -147,6 +147,7 @@ mod scalestrokes; mod scripts; mod select_ids_first; mod shaper; +mod shapetools; mod shortcutset; mod simplify_part; mod slices; diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs new file mode 100644 index 000000000..39198966f --- /dev/null +++ b/crates/engine/src/tests/shapetools.rs @@ -0,0 +1,133 @@ +//! Shape tools: the selection they keep on show moves by its centre point (Attributes › Show +//! Center) and turns from the rotate zone just outside a corner of its box, as the Selection +//! tool's do, through the whole engine (#5477). + +use serde_json::json; +use vectorcraft_doc::{LiveShape, NodeKind}; +use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::*; + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn view() -> ViewInfo { + ViewInfo { smart_guides: false, ..ViewInfo::default() } +} + +fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) { + for (k, x, y) in events { + s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), view()).unwrap(); + } +} + +const NONE: Mods = Mods { shift: false, alt: false, cmd: false, ctrl: false, space: false }; + +/// A 100 × 80 pt rectangle drawn from (100, 100) to (200, 180) with the Rectangle tool, still +/// selected: its centre point is at (150, 140). +fn drawn_rect(s: &mut Session) -> NodeId { + s.select_tool("rectangle", view()).unwrap(); + gesture(s, NONE, &[(PointerKind::Down, 100.0, 100.0), (PointerKind::Drag, 200.0, 180.0), (PointerKind::Up, 200.0, 180.0)]); + *s.doc().unwrap().selection.objects.first().expect("the drawn rectangle is selected") +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.bounds_of(&[id], false).expect("bounds") +} + +fn angle(s: &Session, id: NodeId) -> f64 { + s.doc().unwrap().doc.oriented_bounds(&[id], false).map_or(0.0, |b| b.angle) +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// With the Rectangle tool still active, a drag from the shape's centre point moves it, in one +/// undo step; Alt-dragging it copies it; Escape turns a drag back. +#[test] +fn a_shape_tool_moves_the_shape_from_its_centre_point() { + let mut s = new_doc(); + let id = drawn_rect(&mut s); + let before = bounds(&s, id); + let steps = undo_steps(&s); + gesture( + &mut s, + NONE, + &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 160.0), (PointerKind::Drag, 180.0, 170.0), (PointerKind::Up, 180.0, 170.0)], + ); + assert_eq!(bounds(&s, id), before + Vec2::new(30.0, 30.0), "moved by the drag"); + assert_eq!(undo_steps(&s), steps + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(bounds(&s, id), before); + // Alt-dragging from the centre leaves a copy where the original was. + gesture( + &mut s, + Mods { alt: true, ..NONE }, + &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 200.0, 140.0), (PointerKind::Up, 200.0, 140.0)], + ); + let objects = s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(); + assert_eq!(objects, 2, "a copy"); + // Escape turns the drag back, and the release leaves the art alone. + let steps = undo_steps(&s); + let moved = s.doc().unwrap().selection.objects.clone(); + let copy = *moved.first().expect("the copy is selected"); + let at = bounds(&s, copy); + gesture(&mut s, NONE, &[(PointerKind::Down, 200.0, 140.0), (PointerKind::Drag, 250.0, 190.0)]); + s.tool_key(ToolKey::Escape, NONE, view()).unwrap(); + gesture(&mut s, NONE, &[(PointerKind::Up, 250.0, 190.0)]); + assert_eq!(undo_steps(&s), steps, "no step"); + assert_eq!(s.doc().unwrap().selection.objects, moved); + assert_eq!(bounds(&s, copy), at, "back where it was"); +} + +/// A press in the rotate zone just outside a corner of the shape's box turns it about that box's +/// centre, as the Selection tool's does; a press on the corner itself still rounds it. +#[test] +fn a_shape_tool_turns_the_shape_from_just_outside_a_corner() { + let mut s = new_doc(); + let id = drawn_rect(&mut s); + // 8.49 pt outside the top-left corner (100, 100), past the handle's 5 pt square. + gesture( + &mut s, + NONE, + &[(PointerKind::Down, 94.0, 94.0), (PointerKind::Drag, 150.0, 70.0), (PointerKind::Drag, 150.0, 70.0), (PointerKind::Up, 150.0, 70.0)], + ); + let turned = angle(&s, id); + assert!(turned.abs() > 1.0, "turned: {turned}"); + let centre = bounds(&s, id).center(); + assert!((centre - Point::new(150.0, 140.0)).hypot() < 1e-6, "about its centre: {centre:?}"); + // A press on a Live Corners widget rounds it, as before (#5477): the widget keeps the corner, + // and sits 10 pt inside it. + let mut s = new_doc(); + let id = drawn_rect(&mut s); + gesture(&mut s, NONE, &[(PointerKind::Down, 110.0, 110.0), (PointerKind::Drag, 125.0, 125.0), (PointerKind::Up, 125.0, 125.0)]); + let n = s.doc().unwrap().doc.node(id).expect("the rectangle"); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("a live shape: {:?}", n.kind) }; + let LiveShape::Rectangle { radii, .. } = &**live else { panic!("a rectangle") }; + assert!(radii[0] > 0.0, "the corner rounded: {radii:?}"); + assert_eq!(angle(&s, id), 0.0, "rounding does not turn it"); +} + +/// The Selection tool takes the centre point of an unfilled shape too: a drag there moves it +/// instead of starting a marquee. +#[test] +fn the_selection_tool_moves_an_unfilled_shape_from_its_centre_point() { + let mut s = new_doc(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("selection", view()).unwrap(); + let before = bounds(&s, id); + gesture(&mut s, NONE, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 160.0), (PointerKind::Up, 170.0, 160.0)]); + assert_eq!(bounds(&s, id), before + Vec2::new(20.0, 20.0)); + // Away from it, a drag is still a marquee. + let steps = undo_steps(&s); + gesture(&mut s, NONE, &[(PointerKind::Down, 300.0, 300.0), (PointerKind::Drag, 350.0, 350.0), (PointerKind::Up, 350.0, 350.0)]); + assert_eq!(undo_steps(&s), steps, "a marquee changes no art"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the marquee took nothing"); +} diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 7071f24e8..aa1f28dc0 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -94,7 +94,7 @@ id = "Move" group = "Selection" shown_when = [ "The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).", - "The shape tools over a selected object's centre point (Attributes › Show Center), and while dragging it.", + "The shape tools over a selected object's centre point (Attributes › Show Center), and while dragging it (SelectionCopy with Alt, ArrowHollow while it snaps).", "Reshape over a path or while dragging.", "Inside the Free Transform and Crop Image boxes.", "Puppet Warp over a pin.", diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index fd6cc0f5c..5a75c2a84 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -527,13 +527,25 @@ enum BoxHit { Rotate, } +/// How far (screen points) outside a corner of the bounding box a press turns the selection: the +/// Selection tool's rotate zone, and the shape tools' (#5477). +pub(crate) const ROTATE_ZONE: f64 = 18.0; + +/// Where a press at `p` just outside a corner of box `b` turns the selection about its centre, as +/// the Selection tool's rotate zone does: the box's centre. None on a handle, on the corner itself +/// or away from every corner. +pub(crate) fn rotate_zone(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { + let (tol, lp) = (cx.tol(5.0), b.to_local(p)); + (hit_handle(b.rect, lp, tol).is_none() && in_rotate_zone(b.rect, lp, tol, cx.tol(ROTATE_ZONE)).is_some()).then(|| b.center()) +} + /// The bounding-box handle or rotate zone under `p`. fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { let (tol, lp) = (cx.tol(5.0), b.to_local(p)); if let Some(h) = hit_handle(b.rect, lp, tol) { return Some(BoxHit::Handle(h)); } - in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate) + in_rotate_zone(b.rect, lp, tol, cx.tol(ROTATE_ZONE)).map(|_| BoxHit::Rotate) } /// Whether the current selection's bounding-box handle is under `p`, using the same geometry as @@ -838,13 +850,14 @@ impl Tool for SelectionTool { // 2b. A selected object's centre point (Attributes › Show Center), drawn while it // is selected: a drag from it moves the selection, also where no art is painted // (an unfilled shape), which a press there would otherwise take for a marquee. A - // click there is a click on that object, so it makes it the key object too. - if !m.shift - && !m.cmd + // click there is a click on that object: it makes it the key object of several, + // and a Shift-click takes it out of the selection on release. + if !m.cmd && let Some((id, center)) = hit_center(cx, p, cx.point_tol()) { - let key = ((cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty()) && !m.alt).then_some(id); - self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect: None, key, exclude: None }; + let several = cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty(); + let (deselect, key) = if m.shift { (Some(id), None) } else { (None, (several && !m.alt).then_some(id)) }; + self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect, key, exclude: None }; return vec![]; } // 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling). diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 027bfb203..7268d0678 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2169,3 +2169,33 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() { assert!(a.is_empty(), "{a:?}"); assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee"); } + +/// A Shift-press on a selected object's centre point moves the selection too, held to 45° steps, +/// and a Shift-click there (no drag) takes the object out of the selection, as a Shift-press on +/// the object itself does. +#[test] +fn a_shift_press_on_the_centre_point_moves_or_deselects() { + let (d, id) = doc_with_live_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Mods::default() }; + let centre = Point::new(150.0, 150.0); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y).with_mods(shift)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 160.0).with_mods(shift)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); + let Some(Action::Preview(cmd, v)) = a.last().filter(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")) else { panic!("{a:?}") }; + assert_eq!(cmd, "object.transform"); + let [_, _, _, _, dx, dy] = [0, 1, 2, 3, 4, 5].map(|i| v["matrix"][i].as_f64().unwrap_or_default()); + assert!(dx != 0.0 && dx == dy, "held to 45°: {dx} {dy}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 160.0).with_mods(shift)), vec![Action::Commit]); + // A Shift-click there takes the object out of the selection on release. + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y).with_mods(shift)).is_empty()); + assert_eq!( + t.pointer(&c, &ev(PointerKind::Up, centre.x, centre.y).with_mods(shift)), + vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))] + ); +} diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index b1d2a8558..d513b1f5e 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -18,11 +18,11 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; -use crate::bbox::{in_rotate_zone, move_delta, rotate_for_drag}; +use crate::bbox::{move_delta, rotate_for_drag}; use crate::corners::{self, CornerDrag}; use crate::guides::{DrawSnap, Leave, square}; use crate::pie::{self, PieDrag}; -use crate::select::{MoveSnap, hit_center, matrix_json, shown_box}; +use crate::select::{MoveSnap, hit_center, matrix_json, rotate_zone, shown_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// The largest corner radius the Rounded Rectangle tool draws, points: the range of General › @@ -30,10 +30,6 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool /// value stop there too. pub const MAX_CORNER_RADIUS: f64 = 1296.0; -/// How far (screen points) outside a corner of the selection's box a press with a shape tool turns -/// the selection, as the Selection tool's rotate zone does. -const ROTATE_ZONE: f64 = 18.0; - /// Does tool `id` keep the selection's bounding box and Live Shape widgets on show and let its /// corner and pie widgets be dragged (#5477)? The shape tools that draw a box: not Line Segment. pub fn shows_shape_widgets(id: &str) -> bool { @@ -83,6 +79,9 @@ pub struct ShapeTool { guides: Vec, /// The measurement label of a move or a turn: where it goes and what it reads. measure: Option<(Point, String)>, + /// The move of the selection snapped to a point, a guide or the grid at its last step: the + /// arrow turns hollow, as the Selection tool's does. + snapped: bool, } /// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each @@ -122,6 +121,7 @@ impl ShapeTool { selection: None, guides: vec![], measure: None, + snapped: false, } } @@ -145,11 +145,11 @@ impl ShapeTool { shows_shape_widgets(self.id).then(|| shown_box(cx)).flatten() } - /// Is `p` just outside a corner of the selection's box, where a press turns the selection about - /// its centre ([`ROTATE_ZONE`])? On the corner itself the Live Corners widget rounds it (#5477). + /// Is `p` in the zone just outside a corner of the selection's box, where a press turns the + /// selection about its centre ([`crate::select::rotate_zone`])? On the corner itself the Live + /// Corners widget rounds it (#5477). fn over_rotate(&self, cx: &ToolContext, p: Point) -> Option { - let bx = self.box_of(cx)?; - in_rotate_zone(bx.rect, bx.to_local(p), cx.tol(5.0), cx.tol(ROTATE_ZONE)).map(|_| bx.center()) + rotate_zone(cx, &self.box_of(cx)?, p) } /// Is `p` over the centre point of a selected object (Attributes › Show Center), where a press @@ -194,11 +194,11 @@ impl ShapeTool { out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); } let shift = ev.mods.shift; - let (delta, guides) = if shift { - snap.snap_along(cx, *hold, move_delta(*start, ev.pos, true, cx.constrain_angle), ev.mods.alt) - } else { - snap.snap(cx, *hold, ev.pos - *start, ev.mods.alt) - }; + let unsnapped = move_delta(*start, ev.pos, shift, cx.constrain_angle); + let (delta, guides) = + if shift { snap.snap_along(cx, *hold, unsnapped, ev.mods.alt) } else { snap.snap(cx, *hold, unsnapped, ev.mods.alt) }; + // Shift has already constrained the move: any change is a snap. + self.snapped = (delta - unsnapped).hypot() > 1e-9; self.guides = guides; self.measure = cx.measurement_labels.then(|| (ev.pos, cx.offset_label(delta.x, delta.y))); out.push(Action::Preview("object.transform".into(), json!({"matrix": matrix_json(Affine::translate(delta)), "copy": ev.mods.alt}))); @@ -214,6 +214,7 @@ impl ShapeTool { self.selection = None; self.guides.clear(); self.measure = None; + self.snapped = false; if began { vec![Action::Commit] } else { vec![] } } (PointerKind::Up, SelectionDrag::Rotate { .. }) => { @@ -231,6 +232,7 @@ impl ShapeTool { self.selection = None; self.guides.clear(); self.measure = None; + self.snapped = false; vec![Action::Cancel] } @@ -586,7 +588,7 @@ impl Tool for ShapeTool { } o } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { // Over a widget a press would drag (or dragging one), the Selection tool's pointer for it. let idle = self.start.is_none(); match &self.widget { @@ -594,26 +596,30 @@ impl Tool for ShapeTool { Some(WidgetDrag::Pie(_)) => return Cursor::ArrowHollow, None => {} } + // Alt-dragging the selection leaves a copy behind; otherwise the arrow, hollow while the + // move snaps, as the Selection tool's. match &self.selection { Some(SelectionDrag::Rotate { center, .. }) => return Cursor::rotate_about_in_view(*center, p, cx.view_angle()), + Some(SelectionDrag::Move { began: true, .. }) if m.alt => return Cursor::SelectionCopy, + Some(SelectionDrag::Move { began: true, .. }) => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }, Some(_) => return Cursor::Arrow, None => {} } - // The selection's centre point moves it and the zone just outside a corner turns it; - // everywhere else a press draws a new shape. + // A press takes the corner and pie widgets first, then the centre point, then the zone + // just outside a corner; everywhere else it draws a new shape. if idle { - if self.over_center(cx, p) { - return Cursor::Move; - } - if let Some(center) = self.over_rotate(cx, p) { - return Cursor::rotate_about_in_view(center, p, cx.view_angle()); - } if shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) { return Cursor::CornerRadius; } if self.over_widget(cx, p) { return Cursor::ArrowHollow; } + if self.over_center(cx, p) { + return Cursor::Move; + } + if let Some(center) = self.over_rotate(cx, p) { + return Cursor::rotate_about_in_view(center, p, cx.view_angle()); + } } Cursor::Crosshair } diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index e53cea057..9f45a6630 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -469,9 +469,15 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn t.pointer(&hidden, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); let a = t.pointer(&hidden, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0)); assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); - // Just outside the bottom-right corner (7.07 pt away, past the corner widget's 5): a press - // there turns the selection about its centre. - let outside = Point::new(205.0, 205.0); + // On the corner's bounding-box handle, and on the corner widget itself, no turn: those keep + // their own gestures (the widget rounds the corner, the handle scales with the Selection tool). + for on_handle in [Point::new(205.0, 205.0), Point::new(200.0, 200.0)] { + let t = ShapeTool::new("rectangle"); + assert_ne!(t.cursor(&c, on_handle, Mods::default()), Cursor::rotate_about(centre, on_handle), "{on_handle:?}"); + } + // Just outside the bottom-right corner, past the handle's 5 pt square and within the 18 pt + // zone (8.49 pt away): a press there turns the selection about its centre. + let outside = Point::new(206.0, 206.0); let mut t = ShapeTool::new("rectangle"); assert_eq!(t.cursor(&c, outside, Mods::default()), Cursor::rotate_about(centre, outside)); // A turn begins with the press, as the Selection tool's does (its drags preview into it). diff --git a/docs/roadmap.md b/docs/roadmap.md index dd5504b0d..e29338b76 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -118,6 +118,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1a9c2c2c7..3da7b29cf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -796,7 +796,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). 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. - - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). 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. 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). - **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, 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 c36971afe9dd3fc615b81b48b98bdd5755352a70 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:05:08 -0500 Subject: [PATCH 28/92] Shape tools: the centre point's cursor comes after the box's handles The Selection tool's cursor now checks the bounding-box handles before a selected object's centre point, as a press does (on a thin box the centre lies within a handle's reach). The shape tools' tests cover a Live Corners widget that lands on another selected shape's centre (the widget rounds), Alt pressed mid-drag (the preview copies and the copy cursor shows), and the engine tests check the copy's place and both gestures' Undo and Redo. The cursor catalog says which arrow a drag of the selection shows. --- crates/engine/src/tests/shapetools.rs | 21 +++++++++-- crates/tools/src/cursors.toml | 4 +- crates/tools/src/select.rs | 11 +++--- crates/tools/src/shape/tests.rs | 53 +++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs index 39198966f..8f2b181e2 100644 --- a/crates/engine/src/tests/shapetools.rs +++ b/crates/engine/src/tests/shapetools.rs @@ -64,14 +64,23 @@ fn a_shape_tool_moves_the_shape_from_its_centre_point() { assert_eq!(undo_steps(&s), steps + 1, "one undo step"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(bounds(&s, id), before); - // Alt-dragging from the centre leaves a copy where the original was. + // Alt-dragging from the centre leaves a copy where the original was, in one undo step. + let steps = undo_steps(&s); gesture( &mut s, Mods { alt: true, ..NONE }, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 200.0, 140.0), (PointerKind::Up, 200.0, 140.0)], ); - let objects = s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(); - assert_eq!(objects, 2, "a copy"); + let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(); + assert_eq!(objects(&s), 2, "a copy"); + assert_eq!(undo_steps(&s), steps + 1, "one undo step for the copy"); + let copy = *s.doc().unwrap().selection.objects.first().expect("the copy is selected"); + assert_eq!(bounds(&s, id), before, "the original stays"); + assert_eq!(bounds(&s, copy), before + Vec2::new(50.0, 0.0), "the copy moved"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s), 1, "undone"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(objects(&s), 2, "redone"); // Escape turns the drag back, and the release leaves the art alone. let steps = undo_steps(&s); let moved = s.doc().unwrap().selection.objects.clone(); @@ -101,6 +110,12 @@ fn a_shape_tool_turns_the_shape_from_just_outside_a_corner() { assert!(turned.abs() > 1.0, "turned: {turned}"); let centre = bounds(&s, id).center(); assert!((centre - Point::new(150.0, 140.0)).hypot() < 1e-6, "about its centre: {centre:?}"); + // One undo step for the turn, and Redo turns it again. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(angle(&s, id), 0.0, "undone"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!((angle(&s, id) - turned).abs() < 1e-9, "redone"); + s.execute("edit.undo", &json!({})).unwrap(); // A press on a Live Corners widget rounds it, as before (#5477): the widget keeps the corner, // and sits 10 pt inside it. let mut s = new_doc(); diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index aa1f28dc0..c5b0f5232 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -26,7 +26,7 @@ id = "Arrow" group = "Selection" shown_when = [ "The Selection tool over empty canvas or over an unselected object (no Alt).", - "Dragging with the Selection tool (a move), or with Direct Selection (an anchor, a segment or a handle).", + "Dragging with the Selection tool (a move), with Direct Selection (an anchor, a segment or a handle), or with a shape tool (from the selection's centre point).", "Any tool with no cursor of its own (Touch Type, Rotate View).", "The resting state of Live Paint Selection, Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.", ] @@ -94,7 +94,7 @@ id = "Move" group = "Selection" shown_when = [ "The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).", - "The shape tools over a selected object's centre point (Attributes › Show Center), and while dragging it (SelectionCopy with Alt, ArrowHollow while it snaps).", + "The shape tools over a selected object's centre point (Attributes › Show Center), before a press there moves it (while the drag runs the arrow is plain, SelectionCopy with Alt, ArrowHollow while it snaps).", "Reshape over a path or while dragging.", "Inside the Free Transform and Crop Image boxes.", "Puppet Warp over a pin.", diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 5a75c2a84..eee24b4e9 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1109,11 +1109,6 @@ impl Tool for SelectionTool { if near_selected && pie::over_widget(cx, p) { return Cursor::ArrowHollow; } - // A selected object's centre point (Show Center): a press there moves it, where the art - // may not reach. - if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { - return Cursor::Move; - } if over_rotate_all(cx, p) { return Cursor::Rotate; } @@ -1133,6 +1128,12 @@ impl Tool for SelectionTool { None => {} } } + // A selected object's centre point (Show Center): a press there moves it, where the art + // may not reach. The box's handles take a press before it, so they take the pointer too + // (on a thin box the centre lies within their reach). + if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { + return Cursor::Move; + } if let Some(c) = self.guide.cursor(cx, p) { return c; } diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 9f45a6630..60c3058e6 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -462,6 +462,15 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 160.0)), vec![Action::Commit], "{id}"); assert!(!t.busy() && !t.transforming(), "{id}"); } + // Alt pressed once the drag has begun copies, and the copy cursor shows, as the Selection + // tool's do. + let mut t = ShapeTool::new("rectangle"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 150.0)); + let alt = Mods { alt: true, ..Mods::default() }; + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 150.0).with_mods(alt)); + assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["copy"] == json!(true)), "{a:?}"); + assert_eq!(t.cursor(&c, Point::new(170.0, 150.0), alt), Cursor::SelectionCopy); // View › Hide Bounding Box hides the box, not the centre point: a press there still moves. let hidden = ToolContext { show_bbox: false, ..cx(&d, &s, &p) }; let mut t = ShapeTool::new("rectangle"); @@ -497,3 +506,47 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 350.0, 340.0)); assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}"); } + +/// Two selected live rectangles, the second placed so that its top-left Live Corners widget (10 pt +/// in along the corner's bisector) lands on the first one's centre point. +fn two_live_rects() -> (vectorcraft_doc::Document, Selection) { + use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; + let (mut d, first) = doc_with_live_rect(); + let inside = 10.0 / std::f64::consts::SQRT_2; + let shape = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((150.0 - inside, 150.0 - inside)), + }; + let (layer, id) = (d.layers[0].id, d.alloc_id()); + let mut n = Node::path(id, shape.to_path(), Appearance::default()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(shape)); + } + d.insert(Some(layer), 1, n).unwrap(); + let mut s = Selection::default(); + s.add(first); + s.add(id); + (d, s) +} + +/// A press takes a Live Corners widget before a centre point, as the Selection tool does: with two +/// shapes selected, one's corner widget may sit on another's centre, and it rounds rather than +/// moving the selection. +#[test] +fn a_live_corner_widget_wins_over_another_shapes_centre_point() { + let (d, s) = two_live_rects(); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(150.0, 150.0); + assert!(corners::over_widget(&c, at, false), "a widget is on the centre point"); + assert!(hit_center(&c, at, c.point_tol()).is_some(), "and so is the centre point"); + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::CornerRadius, "the widget takes the pointer"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)).is_empty()); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 160.0)); + assert_eq!(a.first(), Some(&Action::Begin("Corner Radius".into())), "{a:?}"); +} From 6d3c0fef99cf3de9ed8047238969fd5f4f93bbcf Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:11:32 -0500 Subject: [PATCH 29/92] Shape tools: guides take the centre point's pointer too, and more regressions The Selection tool's cursor now checks the ruler guides before a selected object's centre point, as a press does. Tests: a ruler guide under the centre point keeps the pointer and drags; on a box too thin for its centre to clear the handles the handles take both; a shape-tool move from the centre snaps and shows the hollow arrow; and Alt pressed mid-drag copies the shape through the engine. --- crates/engine/src/tests/shapetools.rs | 14 ++++++++ crates/tools/src/select.rs | 12 +++---- crates/tools/src/select/tests.rs | 51 +++++++++++++++++++++++++++ crates/tools/src/shape/tests.rs | 26 ++++++++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs index 8f2b181e2..3ecca4bef 100644 --- a/crates/engine/src/tests/shapetools.rs +++ b/crates/engine/src/tests/shapetools.rs @@ -94,6 +94,20 @@ fn a_shape_tool_moves_the_shape_from_its_centre_point() { assert_eq!(bounds(&s, copy), at, "back where it was"); } +/// Alt pressed once the drag has begun turns the move into a copy, as the Selection tool's. +#[test] +fn alt_pressed_mid_drag_copies_the_shape() { + let mut s = new_doc(); + let id = drawn_rect(&mut s); + let before = bounds(&s, id); + gesture(&mut s, NONE, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 140.0)]); + gesture(&mut s, Mods { alt: true, ..NONE }, &[(PointerKind::Drag, 200.0, 140.0), (PointerKind::Up, 200.0, 140.0)]); + assert_eq!(s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(), 2, "a copy"); + assert_eq!(bounds(&s, id), before, "the original stays"); + let copy = *s.doc().unwrap().selection.objects.first().expect("the copy is selected"); + assert_eq!(bounds(&s, copy), before + Vec2::new(50.0, 0.0), "the copy moved by the whole drag"); +} + /// A press in the rotate zone just outside a corner of the shape's box turns it about that box's /// centre, as the Selection tool's does; a press on the corner itself still rounds it. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index eee24b4e9..8fc737937 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1128,15 +1128,15 @@ impl Tool for SelectionTool { None => {} } } - // A selected object's centre point (Show Center): a press there moves it, where the art - // may not reach. The box's handles take a press before it, so they take the pointer too - // (on a thin box the centre lies within their reach). - if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { - return Cursor::Move; - } if let Some(c) = self.guide.cursor(cx, p) { return c; } + // A selected object's centre point (Show Center): a press there moves it, where the art + // may not reach. The box's handles and the ruler guides take a press before it, so they + // take the pointer too (on a thin box the centre lies within a handle's reach). + if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { + return Cursor::Move; + } // View → Snap to Point: over an anchor a press would grab the object by, the arrow turns // hollow. if cx.snap_to_point && matches!(Self::hover_point(cx, p), Some((_, "anchor"))) { diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 7268d0678..c37a35455 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2199,3 +2199,54 @@ fn a_shift_press_on_the_centre_point_moves_or_deselects() { vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))] ); } + +/// On a box too thin for its centre point to clear the handles, the handles take the pointer and +/// the press, as they take the press itself: the centre is within their 5 pt reach. +#[test] +fn a_thin_shapes_centre_point_gives_the_handles_the_pointer() { + use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; + let shape = LiveShape::Rectangle { + w: 100.0, + h: 8.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + }; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + let mut n = Node::path(id, shape.to_path(), Appearance::default()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(shape)); + } + d.insert(Some(layer), 0, n).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let centre = Point::new(150.0, 104.0); + assert!(hit_center(&c, centre, c.point_tol()).is_some(), "the centre point is there"); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, centre, Mods::default()), Cursor::ResizeV, "the top and bottom handles take it"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y)), vec![Action::Begin("Scale".into())]); +} + +/// A ruler guide under a selected object's centre point keeps the pointer and the press, as it +/// keeps them over the art. +#[test] +fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() { + let (mut d, id) = doc_with_live_rect(); + d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let centre = Point::new(150.0, 150.0); + assert!(hit_center(&c, centre, c.point_tol()).is_some()); + let mut t = SelectionTool::default(); + let cursor = t.cursor(&c, centre, Mods::default()); + assert_ne!(cursor, Cursor::Move, "the guide's cursor, not the move arrow"); + let down = t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y)); + let drag = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "guide.move")), "the guide drags: {down:?} {drag:?}"); +} diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 60c3058e6..85a2d52fc 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -550,3 +550,29 @@ fn a_live_corner_widget_wins_over_another_shapes_centre_point() { let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 160.0)); assert_eq!(a.first(), Some(&Action::Begin("Corner Radius".into())), "{a:?}"); } + +/// A move of the selection from the centre point snaps like the Selection tool's, and the arrow +/// turns hollow while it does. +#[test] +fn a_move_from_the_centre_point_snaps_and_shows_the_hollow_arrow() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, s) = unfilled_live_rect(); + // Another rectangle whose left edge is at x = 300: the moved selection's right edge lands 4 pt + // short of it, within the Smart Guides' reach. + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 100.0, 350.0, 180.0)), Appearance::default_art())) + .unwrap(); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = ShapeTool::new("rectangle"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 246.0, 150.0)); + assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["matrix"][4] == json!(100.0)), "snapped to the edge: {a:?}"); + assert_eq!(t.cursor(&c, Point::new(246.0, 150.0), Mods::default()), Cursor::ArrowHollow); + // Away from the neighbour, the move is the pointer's own and the arrow is plain. + let mut t = ShapeTool::new("rectangle"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 150.0)); + assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["matrix"][4] == json!(10.0)), "{a:?}"); + assert_eq!(t.cursor(&c, Point::new(160.0, 150.0), Mods::default()), Cursor::Arrow); +} From dd160dc76833b6daff51e346c070e4c08be02d3b Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:14:41 -0500 Subject: [PATCH 30/92] Selection: Cmd/Ctrl over a centre point selects behind, so the arrow stays plain The press with Cmd/Ctrl held selects the object behind rather than taking the centre point; the pointer now agrees. --- crates/tools/src/select.rs | 5 +++-- crates/tools/src/select/tests.rs | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8fc737937..61d215069 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1133,8 +1133,9 @@ impl Tool for SelectionTool { } // A selected object's centre point (Show Center): a press there moves it, where the art // may not reach. The box's handles and the ruler guides take a press before it, so they - // take the pointer too (on a thin box the centre lies within a handle's reach). - if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { + // take the pointer too (on a thin box the centre lies within a handle's reach), and with + // Cmd/Ctrl held the press selects the object behind instead. + if near_selected && !m.cmd && hit_center(cx, p, cx.point_tol()).is_some() { return Cursor::Move; } // View → Snap to Point: over an anchor a press would grab the object by, the arrow turns diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index c37a35455..eb926ffbc 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2153,6 +2153,8 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() { let centre = Point::new(150.0, 150.0); let mut t = SelectionTool::default(); assert_eq!(t.cursor(&c, centre, Mods::default()), Cursor::Move); + // Cmd/Ctrl selects the object behind, not the centre point, so the pointer says neither. + assert_ne!(t.cursor(&c, centre, Mods { cmd: true, ..Mods::default() }), Cursor::Move); assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y)).is_empty()); let a = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 160.0)); assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); From 10724077bcbcf4e622eb5d11108d891f07eefea1 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:25:44 -0500 Subject: [PATCH 31/92] docs: the shape-tool status lines name no other branch's change The roadmap's status line and revision history now record the shape tools' centre-point move and corner rotate (#5477), and the parity status line no longer names the Transform tab-order fix, which is its own pull request. --- docs/roadmap.md | 3 ++- docs/ui-parity.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index e29338b76..d9ac18528 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -179,6 +179,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep their own units; scores and estimates unchanged. | | 2026-11-03 | minor | M7: the OpenType panel offers the selected font's available Stylistic Sets with custom or numbered names; estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1062d89fd..4efbee4aa 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; Transform fields: one Tab per field; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence From 1c271c776ca640df3d42c59cacbfaa7632705267 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:23:18 -0500 Subject: [PATCH 32/92] Shape tools: review follow-ups from CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The 45° move assertion compares dx and dy with a small tolerance instead of exact equality (the matrix's floats are rounded), keeping the non-zero check and the message. - The engine test helpers say what they do, so the docstring coverage of the functions this pull request touches is complete. --- crates/engine/src/tests/shapetools.rs | 7 +++++++ crates/tools/src/select/tests.rs | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs index 3ecca4bef..3c7d34a4f 100644 --- a/crates/engine/src/tests/shapetools.rs +++ b/crates/engine/src/tests/shapetools.rs @@ -9,22 +9,26 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use crate::*; +/// A new 400 × 400 pt document. fn new_doc() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); s } +/// The view the gestures run in: page coordinates, with Smart Guides off so nothing snaps. fn view() -> ViewInfo { ViewInfo { smart_guides: false, ..ViewInfo::default() } } +/// Feed the pointer events `(kind, x, y)`, all with `mods` held, to the active tool. fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) { for (k, x, y) in events { s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), view()).unwrap(); } } +/// No modifier held. const NONE: Mods = Mods { shift: false, alt: false, cmd: false, ctrl: false, space: false }; /// A 100 × 80 pt rectangle drawn from (100, 100) to (200, 180) with the Rectangle tool, still @@ -35,14 +39,17 @@ fn drawn_rect(s: &mut Session) -> NodeId { *s.doc().unwrap().selection.objects.first().expect("the drawn rectangle is selected") } +/// The geometric bounds of `id`. fn bounds(s: &Session, id: NodeId) -> Rect { s.doc().unwrap().doc.bounds_of(&[id], false).expect("bounds") } +/// The angle of `id`'s bounding box, in degrees counter-clockwise: 0 for art square to the page. fn angle(s: &Session, id: NodeId) -> f64 { s.doc().unwrap().doc.oriented_bounds(&[id], false).map_or(0.0, |b| b.angle) } +/// How many undo steps the active document's history holds. fn undo_steps(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index eb926ffbc..94fb67786 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2191,7 +2191,8 @@ fn a_shift_press_on_the_centre_point_moves_or_deselects() { let Some(Action::Preview(cmd, v)) = a.last().filter(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")) else { panic!("{a:?}") }; assert_eq!(cmd, "object.transform"); let [_, _, _, _, dx, dy] = [0, 1, 2, 3, 4, 5].map(|i| v["matrix"][i].as_f64().unwrap_or_default()); - assert!(dx != 0.0 && dx == dy, "held to 45°: {dx} {dy}"); + // A 45° move, within the rounding of the matrix's own floats. + assert!(dx != 0.0 && (dx - dy).abs() < 1e-9, "held to 45°: {dx} {dy}"); assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 160.0).with_mods(shift)), vec![Action::Commit]); // A Shift-click there takes the object out of the selection on release. let mut t = SelectionTool::default(); From d572f3ce711a0c80947aad0e529b075d119b5997 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:38:22 -0500 Subject: [PATCH 33/92] Shape tools: the tool methods this pull request changes say what they do The `Tool` implementation's `busy`, `pointer`, `key`, `claims_key`, `overlays` and `cursor` in shape.rs, and `pointer` and `cursor` in select.rs, are the ones this pull request edits; each now has a doc comment, so the docstring coverage of the functions the diff touches is complete. --- crates/tools/src/select.rs | 4 ++++ crates/tools/src/shape.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 61d215069..0f879cb55 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -804,6 +804,8 @@ impl Tool for SelectionTool { } } + /// A press takes the selection's widgets, a ruler guide, a selected object's centre point or + /// the art; the drags then move, scale, rotate, marquee or drag what it took. fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.note_loaded_at_press(ev.kind); let hovering = ev.kind == PointerKind::Move && matches!(self.state, State::Idle); @@ -1061,6 +1063,8 @@ impl Tool for SelectionTool { o } + /// The pointer for the drag in progress, else the one for what a press would take: a widget, + /// a ruler guide, the box's handles or rotate zone, a selected object's centre point or the art. fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { if let State::Mirror(d) = &self.state { return d.cursor(cx, p); diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index d513b1f5e..076525be4 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -391,9 +391,13 @@ impl Tool for ShapeTool { fn id(&self) -> &'static str { self.id } + /// A shape being drawn, a Live Shape widget dragged, or the selection moved or turned is a + /// gesture in progress. fn busy(&self) -> bool { self.start.is_some() || self.widget.is_some() || self.selection.is_some() } + /// A widget or selection drag takes the pointer; otherwise a press draws, unless it lands on + /// the selection's centre point or in the rotate zone just outside its box's corner. fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { if let Some(out) = self.widget_pointer(cx, ev) { return out; @@ -469,6 +473,8 @@ impl Tool for ShapeTool { _ => vec![], } } + /// ↑/↓ change a polygon's sides, a star's points or the rounded rectangle's radius while it is + /// drawn; Escape turns a widget or selection drag back. fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { // A drag of the selection: Escape turns it back, as with the Selection tool. if self.selection.is_some() { @@ -540,6 +546,8 @@ impl Tool for ShapeTool { }, } } + /// ↑/↓ step a corner's kind while its widget is dragged, and Escape ends a drag of the + /// selection. fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { match (&self.widget, &self.selection) { (Some(WidgetDrag::Corner(_)), _) => matches!(key, ToolKey::Up | ToolKey::Down), @@ -547,6 +555,8 @@ impl Tool for ShapeTool { _ => false, } } + /// The widget being dragged, the Smart Guides of the shape being drawn or the selection being + /// moved, and its size, angle or offset label. fn overlays(&self, cx: &ToolContext) -> Vec { match &self.widget { Some(WidgetDrag::Corner(c)) => return c.overlays(cx), @@ -588,6 +598,9 @@ impl Tool for ShapeTool { } o } + /// The Selection tool's pointer over a Live Shape widget, the selection's centre point or the + /// rotate zone just outside its box's corner; the crosshair everywhere else, where a press + /// draws. fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { // Over a widget a press would drag (or dragging one), the Selection tool's pointer for it. let idle = self.start.is_none(); From babbbb9eb0349f32dd363ad2e9ed119f83516957 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 07:07:13 -0800 Subject: [PATCH 34/92] .ai import: ASCII85 data in a section does not hide the section's end A section scan lexed a /ForeignObject's /Data, a /Binary dictionary's data and a text document's data as tokens. A `%_` line of that data holding a `(` opened a string, so the section ran past its end marker: the editing data failed to read, or the art after it was misread. Reader::section now skips the data up to its `~>`, as the reader does for art outside a section. Data without its `~>` before the section's end marker is not skipped. The scan for swatch names does the same. --- crates/engine/tests/import_fuzz.rs | 14 ++- crates/eps/src/import/ai/lex.rs | 3 +- crates/eps/src/import/ai/read.rs | 4 + crates/eps/src/import/ai/read/directives.rs | 6 +- crates/eps/src/import/ai/read/ops.rs | 97 ++++++++++++++++++++- crates/eps/src/import/ai/tests.rs | 92 +++++++++++++++++++ 6 files changed, 206 insertions(+), 10 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index aac335bf6..d4d545b9e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3633,8 +3633,8 @@ proptest! { } } -/// Editing data with two symbols (S2's art holds an instance of S1), the symbol list and a layer of -/// instances, as the format writes them. +/// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the +/// symbol list and a layer of instances, as the format writes them. fn symbols_text() -> String { use vectorcraft_testkit::ai; let def = |name: &str, art: &str, reg: u32| { @@ -3643,7 +3643,12 @@ fn symbols_text() -> String { ) }; let inst = |name: &str, m: &str| format!("/SymbolInstance :\n({name}) /SymbolRef ,\n{m} /RTransformMatrix\n;\n9 () XW\n"); - let defs = def("S1", "0 1 1 0 1 0 0 Xa\n-2 -2 m\n2 -2 L\n2 2 L\n-2 2 L\nf\n", 5) + &def("S2", &inst("S1", "1 0 0 -1 -8081 8237"), 1); + // Non-native art keeps ASCII85 data in comment lines, some of them `%_` lines holding a `(`. + let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n\ + %,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n"; + let defs = def("F", &format!("{foreign}-2 -2 m\n2 -2 L\n2 2 L\nf\n"), 5) + + &def("S1", "0 1 1 0 1 0 0 Xa\n-2 -2 m\n2 -2 L\n2 2 L\n-2 2 L\nf\n", 5) + + &def("S2", &inst("S1", "1 0 0 -1 -8081 8237"), 1); let list = "%AI24_BeginSymbolList\n(S2)\n(S1)\n%AI24_EndSymbolList\n"; let page = ai::layer( "L", @@ -3668,7 +3673,7 @@ fn the_symbols_fuzz_data_opens_live() { let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&symbols_text())].concat(); let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); assert!(layered, "{warnings:?}"); - let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).filter(|n| *n != "F").collect(); assert_eq!(names, ["S2", "S1"]); let mut instances = 0; doc.walk(|n| instances += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. }))); @@ -4165,6 +4170,7 @@ fn arb_ai_token() -> impl Strategy { "\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\n", "/SymbolInstance :", "/Binary : /ASCII85Decode ,", + "/AI11TextDocument : /ASCII85Decode ,", "~>", "p", "P", diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index b96b85c06..810a01c3f 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -133,7 +133,8 @@ impl<'a> Lexer<'a> { self.src.get(self.pos).copied() } - fn rest(&self) -> &'a [u8] { + /// The data from where the next token is looked for. + pub fn rest(&self) -> &'a [u8] { self.src.get(self.pos..).unwrap_or_default() } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 2cf9dc736..d84338094 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -279,6 +279,8 @@ fn swatch_names(data: &[u8]) -> ShownNames { let (mut palette_at, mut palette) = (None, None); // A pattern's name is the first string of its definition. let mut head = false; + // ASCII85 data in comment lines is skipped past, as the reader skips it. + let mut dictionaries = ops::Dictionaries::default(); loop { let before = lex.offset(); let Some(t) = lex.next_token() else { @@ -291,6 +293,8 @@ fn swatch_names(data: &[u8]) -> ShownNames { _ => break, } }; + let end = skip.map(|s| s.1).or(palette_at.map(|_| "EndPalette")).or(section.map(|s| s.1)); + dictionaries.pass(&t.tok, &mut lex, end); match &t.tok { Tok::Comment(c) => { let line = String::from_utf8_lossy(c); diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 2bd94479d..fbc889c54 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use super::super::brush; use super::art::{four, sane, two}; +use super::ops::Dictionaries; use super::types::{ FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS, PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED, @@ -330,15 +331,18 @@ impl<'a> Reader<'a> { self.section(end).map(|_| ()) } - /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. + /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. ASCII85 data in comment + /// lines is skipped past as [`Self::add_object_entry`] skips it. fn section(&mut self, end: &str) -> Result<&'a [u8], String> { let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; let start = self.lex.offset(); let mut depth = 0usize; + let mut dictionaries = Dictionaries::default(); loop { let before = self.lex.offset(); let Some(t) = self.lex.next_token() else { break }; + dictionaries.pass(&t.tok, &mut self.lex, Some(end)); if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); let m = marker(&line); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index dc3089024..5510663ca 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -34,10 +34,11 @@ impl<'a> Reader<'a> { // of it may start with `%_` and read as hidden tokens: a `(` there would open // a string that swallows the rest of the layer. let key = o.entries.last().and_then(|(k, _)| k.as_deref()); - if o.ty == "Binary" && key == Some("ASCII85Decode") { - self.lex.skip_past(b"~>"); - } else if o.ty == "ForeignObject" && key == Some("Data") { - self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec()); + if ascii85_follows(&o.ty, key) { + let data = self.lex.skip_past(b"~>"); + if o.ty == "ForeignObject" { + self.foreign_data = Some(data.to_vec()); + } } } } @@ -1122,6 +1123,94 @@ fn strip(n: &mut Node, hidden: &HashSet) { } } +/// Whether ASCII85 data in comment lines, up to its `~>`, follows the entry keyed `key` of a +/// dictionary of type `ty`: the `/ASCII85Decode` of a `/Binary` dictionary or a text document, or a +/// non-native art object's (`/ForeignObject`'s) `/Data`. +pub(super) fn ascii85_follows(ty: &str, key: Option<&str>) -> bool { + matches!((ty, key), ("Binary" | "AI11TextDocument" | "AI11UndoFreeTextDocument", Some("ASCII85Decode")) | ("ForeignObject", Some("Data"))) +} + +/// The dictionaries (`/Type :` … `;`) a scan of tokens passes that the reader doesn't read, kept to +/// tell where an entry's ASCII85 data follows ([`ascii85_follows`]), so the scan can skip past it +/// as the reader does: a `(` on a `%_` line of the data would otherwise open a string. +#[derive(Default)] +pub(super) struct Dictionaries<'s> { + /// The open dictionaries' types, innermost last, up to [`MAX_DEPTH`]. + types: Vec<&'s [u8]>, + /// How many more are open inside the last of `types`. + deeper: usize, + /// The name just before the token taken. + name: Option<&'s [u8]>, + /// Whether the string just before the token taken is a key that ASCII85 data follows. + string_key: bool, + /// Data that starts before this offset has no `~>`, so it isn't searched for again. + no_end_before: usize, +} + +/// The offset just past the `~>` of ASCII85 data at the start of `rest`. `Err` holds the offset of +/// the comment with marker `end` that comes first, or the end of `rest`. A line of the data that +/// reads as that marker counts as the marker. +fn data_end(rest: &[u8], end: Option<&str>) -> Result { + let mut at = 0; + for line in rest.split(|b| matches!(b, b'\r' | b'\n')) { + let comment = line.strip_prefix(b"%").and_then(|c| std::str::from_utf8(c).ok()); + if comment.is_some_and(|c| Some(super::marker(c)) == end) { + return Err(at); + } + if let Some(i) = line.windows(2).position(|w| w == b"~>") { + return Ok(at + i + 2); + } + at += line.len() + 1; + } + Err(rest.len()) +} + +impl<'s> Dictionaries<'s> { + /// Take `tok`, the token `lex` just gave, and skip past the ASCII85 data that follows it. Data + /// without its `~>` before `end`, the end marker of the section being scanned ([`data_end`]), + /// isn't skipped, so the scan still finds that marker. + pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, end: Option<&str>) { + let at = lex.offset(); + if self.data_follows(tok) && at >= self.no_end_before { + match data_end(lex.rest(), end) { + Ok(end) => lex.seek(at + end), + Err(stop) => self.no_end_before = at + stop, + } + } + } + + /// Take the next token → whether ASCII85 data follows it. + fn data_follows(&mut self, tok: &super::Tok<'s>) -> bool { + // Comments between a name and what follows it are passed over, as the reader passes them. + if matches!(tok, super::Tok::Comment(_)) { + return false; + } + let name = self.name.take(); + let string_key = std::mem::take(&mut self.string_key); + match tok { + super::Tok::Name(n) => self.name = Some(n), + // A string keys an entry as a name does, but gives a dictionary no type. + super::Tok::Str(s) | super::Tok::Hex(s) => self.string_key = self.keys_data(s), + super::Tok::Word(b":") if self.types.len() < MAX_DEPTH => self.types.push(name.unwrap_or_default()), + super::Tok::Word(b":") => self.deeper = self.deeper.saturating_add(1), + super::Tok::Word(b";") if self.deeper > 0 => self.deeper -= 1, + super::Tok::Word(b";") => { + self.types.pop(); + } + super::Tok::Word(b",") => return string_key || name.is_some_and(|key| self.keys_data(key)), + _ => {} + } + false + } + + /// Whether ASCII85 data follows an entry keyed `key` of the innermost dictionary. + fn keys_data(&self, key: &[u8]) -> bool { + let Some(ty) = self.types.last().filter(|_| self.deeper == 0) else { return false }; + let (Ok(ty), Ok(key)) = (std::str::from_utf8(ty), std::str::from_utf8(key)) else { return false }; + ascii85_follows(ty, Some(key)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 27609b10b..30b53a3e2 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4503,6 +4503,98 @@ fn non_native_art_that_is_not_a_pdf_is_left_out_with_a_note() { assert!(s.warnings.iter().any(|w| w.starts_with(read::NON_NATIVE_ART_UNREAD)), "{:?}", s.warnings); } +/// The same data in a symbol's definition: its `%_` line with a `(` opens no string, so the +/// definition ends at its own end marker and the symbol after it comes in. +#[test] +fn non_native_art_in_a_symbol_ends_at_the_definitions_end() { + let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n"; + let defs = symbols(&(symbol("Pdf", &format!("{foreign}{}", dot()), 5) + &symbol("Dot", &dot(), 5))); + let s = read_ok(&(defs + &layer(&instance("Dot", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + let names: Vec<&str> = s.doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Dot"), "{names:?}"); + let a = art(&s.doc); + assert_eq!(a.len(), 1); + assert!(matches!(&a[0].kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Dot"), "{:?}", a[0].kind); +} + +/// Comment lines between a dictionary's name and its `:`, or an entry's key and its `,`, don't hide +/// the data from the scan of a symbol's definition. +#[test] +fn non_native_art_with_comments_in_a_symbol_ends_at_the_definitions_end() { + let foreign = "/ForeignObject\n%note\n:\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data\n%note\n,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n"; + let defs = symbols(&(symbol("Pdf", &format!("{foreign}{}", dot()), 5) + &symbol("Dot", &dot(), 5))); + let s = read_ok(&(defs + &layer(&instance("Dot", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + let names: Vec<&str> = s.doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Dot"), "{names:?}"); +} + +/// A string keys an entry as a name does: `(Data) ,` doesn't hide the data from the scan of a +/// symbol's definition either. +#[test] +fn non_native_art_keyed_by_a_string_in_a_symbol_ends_at_the_definitions_end() { + for key in ["(Data)", "<44617461>"] { + let foreign = format!( + "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n{key} ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n" + ); + let defs = symbols(&(symbol("Pdf", &format!("{foreign}{}", dot()), 5) + &symbol("Dot", &dot(), 5))); + let s = read_ok(&(defs + &layer(&instance("Dot", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + let names: Vec<&str> = s.doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Dot"), "{key}: {names:?}"); + } +} + +/// A `/Binary` dictionary's data in a skipped alternate: its `%_` line with a `(` opens no string, +/// so the block ends at its own end marker. +#[test] +fn binary_data_in_a_skipped_alternate_ends_at_the_blocks_end() { + let binary = "%_/Document :\n%_/Binary : /ASCII85Decode ,\n%_(]<[%\n%;;;~>\n%_; (Profile) ,\n%_;\n"; + let body = format!( + "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n{binary}frobnicate\n%AI17_End_Versioned_Content\n{}", + layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")) + ); + let s = read_ok(&body); + assert_eq!(art(&s.doc).len(), 1); + assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings); +} + +/// A text document's data, the free type's undo copy included: its `%_` line with a `(` opens no +/// string, even after a line that reads as another section's end marker, so the section ends at +/// its own end marker and the art after it comes in. +#[test] +fn text_document_data_ends_at_the_sections_end() { + for ty in ["AI11TextDocument", "AI11UndoFreeTextDocument"] { + let document = + format!("%AI11_BeginTextDocument\n/{ty} : /ASCII85Decode ,\n%,u@!!/MSk8\n%AI3_EndPattern\n%_(]<[%\n%;;;~>\n%_;\n%AI11_EndTextDocument\n"); + let s = read_ok(&format!("{document}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert_eq!(art(&s.doc).len(), 1, "{ty}"); + } +} + +/// Data without its `~>` isn't skipped: the section still ends at its own end marker, with no `~>` +/// after it, with one in a later section, and with later data that holds a `(` on a `%_` line. +#[test] +fn data_without_its_end_does_not_hide_the_sections_end() { + let unended = "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%AI11_EndTextDocument\n"; + let ended = "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%_(]<[%\n%;;;~>\n%_;\n%AI11_EndTextDocument\n"; + let art_layer = layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")); + for body in [format!("{unended}{art_layer}"), format!("{unended}{art_layer}{ended}"), format!("{unended}{ended}{art_layer}")] { + let s = read_ok(&body); + assert_eq!(art(&s.doc).len(), 1, "{body}"); + } +} + +/// The names the swatches take are found past the same data: a named color after it still takes +/// its name first, and the pattern named after it is numbered. +#[test] +fn swatch_names_are_found_past_non_native_art_data() { + let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n0 0 Xd\n6 () XW\n0 Ae\n"; + let sea = format!("0 1 1 0 (Sea) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let body = format!("{}{}", DOTS.replace("(Dots)", "(Sea\\n1)"), layer(&format!("{foreign}{sea}"))); + let d = read(stream(&body).as_bytes()).unwrap().doc; + let names: Vec<&str> = d.patterns.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Sea 2"]); +} + /// A PDF with one page of `size` points holding `content`. fn tiny_pdf(size: f64, content: &str) -> Vec { let objects = [ From 3e1893d39998ea3e4be181019faece6454909c18 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 08:43:02 -0800 Subject: [PATCH 35/92] .ai import: ASCII85 data without its end does not hide the section's end Data with no `~>` before the section's end marker was lexed as tokens, so a `(` on a `%_` line of it opened a string that ran past the marker and the section did not end. The scan now skips that data up to the section's end marker, or up to the start of a section of the same kind nested after it. A `%` on the key's own line starts no marker, and a marker line with bytes that are not UTF-8 counts, as the scan reads comments. When versioned content does not end, the scan for swatch names goes back to where its dictionaries stood at the start of that content, so data in the range it reads again is skipped. --- crates/eps/src/import/ai/read.rs | 8 ++- crates/eps/src/import/ai/read/directives.rs | 2 +- crates/eps/src/import/ai/read/ops.rs | 61 ++++++++++++++------- crates/eps/src/import/ai/tests.rs | 49 +++++++++++++++++ 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index d84338094..4db08ea21 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -281,6 +281,8 @@ fn swatch_names(data: &[u8]) -> ShownNames { let mut head = false; // ASCII85 data in comment lines is skipped past, as the reader skips it. let mut dictionaries = ops::Dictionaries::default(); + // The scan's state where the versioned content being skipped starts, to go back to. + let mut versioned = None; loop { let before = lex.offset(); let Some(t) = lex.next_token() else { @@ -288,13 +290,14 @@ fn swatch_names(data: &[u8]) -> ShownNames { match skip.take() { Some((.., Some(at))) => { lex.seek(at); + dictionaries = versioned.take().unwrap_or_default(); continue; } _ => break, } }; - let end = skip.map(|s| s.1).or(palette_at.map(|_| "EndPalette")).or(section.map(|s| s.1)); - dictionaries.pass(&t.tok, &mut lex, end); + let markers = skip.map(|s| (s.0, s.1)).or(palette_at.map(|_| ("", "EndPalette"))).or(section.map(|s| (s.0, s.1))); + dictionaries.pass(&t.tok, &mut lex, markers); match &t.tok { Tok::Comment(c) => { let line = String::from_utf8_lossy(c); @@ -310,6 +313,7 @@ fn swatch_names(data: &[u8]) -> ShownNames { } } else if m.starts_with("Alternate_Content") && section.is_none_or(|s| s.3) { skip = Some(("Begin_Content_if_version_gt", "End_Versioned_Content", 0, Some(lex.offset()))); + versioned = Some(dictionaries.clone()); } else if m == "%BeginProlog" { skip = Some(("", "%EndProlog", 0, None)); } else if m == "%BeginResource" { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index fbc889c54..154f917e8 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -342,7 +342,7 @@ impl<'a> Reader<'a> { loop { let before = self.lex.offset(); let Some(t) = self.lex.next_token() else { break }; - dictionaries.pass(&t.tok, &mut self.lex, Some(end)); + dictionaries.pass(&t.tok, &mut self.lex, Some((begin, end))); if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); let m = marker(&line); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 5510663ca..5b2f86102 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1133,7 +1133,7 @@ pub(super) fn ascii85_follows(ty: &str, key: Option<&str>) -> bool { /// The dictionaries (`/Type :` … `;`) a scan of tokens passes that the reader doesn't read, kept to /// tell where an entry's ASCII85 data follows ([`ascii85_follows`]), so the scan can skip past it /// as the reader does: a `(` on a `%_` line of the data would otherwise open a string. -#[derive(Default)] +#[derive(Clone, Default)] pub(super) struct Dictionaries<'s> { /// The open dictionaries' types, innermost last, up to [`MAX_DEPTH`]. types: Vec<&'s [u8]>, @@ -1143,38 +1143,43 @@ pub(super) struct Dictionaries<'s> { name: Option<&'s [u8]>, /// Whether the string just before the token taken is a key that ASCII85 data follows. string_key: bool, - /// Data that starts before this offset has no `~>`, so it isn't searched for again. - no_end_before: usize, + /// The rest of the data has no `~>` or marker to stop at, so they aren't searched for again. + no_end: bool, } -/// The offset just past the `~>` of ASCII85 data at the start of `rest`. `Err` holds the offset of -/// the comment with marker `end` that comes first, or the end of `rest`. A line of the data that -/// reads as that marker counts as the marker. -fn data_end(rest: &[u8], end: Option<&str>) -> Result { +/// Where the ASCII85 data at the start of `rest` stops: just past its `~>`, or at the comment that +/// comes first with a marker of `section`, the begin and end markers of the section being scanned +/// (no begin marker when it's empty). A line of the data that reads as one of them counts as the +/// marker. `None` when `rest` has neither. +fn data_end(rest: &[u8], section: Option<(&str, &str)>) -> Option { let mut at = 0; for line in rest.split(|b| matches!(b, b'\r' | b'\n')) { - let comment = line.strip_prefix(b"%").and_then(|c| std::str::from_utf8(c).ok()); - if comment.is_some_and(|c| Some(super::marker(c)) == end) { - return Err(at); + // The first line is what is left of the key's line, where a `%` starts no marker. + if at > 0 + && let Some((comment, (begin, end))) = line.strip_prefix(b"%").zip(section) + { + let comment = String::from_utf8_lossy(comment); + let marker = super::marker(&comment); + if marker == end || (!begin.is_empty() && marker == begin) { + return Some(at); + } } if let Some(i) = line.windows(2).position(|w| w == b"~>") { - return Ok(at + i + 2); + return Some(at + i + 2); } at += line.len() + 1; } - Err(rest.len()) + None } impl<'s> Dictionaries<'s> { - /// Take `tok`, the token `lex` just gave, and skip past the ASCII85 data that follows it. Data - /// without its `~>` before `end`, the end marker of the section being scanned ([`data_end`]), - /// isn't skipped, so the scan still finds that marker. - pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, end: Option<&str>) { - let at = lex.offset(); - if self.data_follows(tok) && at >= self.no_end_before { - match data_end(lex.rest(), end) { - Ok(end) => lex.seek(at + end), - Err(stop) => self.no_end_before = at + stop, + /// Take `tok`, the token `lex` just gave, and skip the ASCII85 data that follows it, up to where + /// [`data_end`] stops for `section`, so the scan still finds that section's markers. + pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, section: Option<(&str, &str)>) { + if self.data_follows(tok) && !self.no_end { + match data_end(lex.rest(), section) { + Some(stop) => lex.seek(lex.offset() + stop), + None => self.no_end = true, } } } @@ -1221,6 +1226,20 @@ mod tests { Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(fill, Paint::None, 0.0))) } + #[test] + fn data_stops_at_its_end_or_a_marker_of_the_section() { + let section = Some(("BeginTextDocument", "EndTextDocument")); + assert_eq!(data_end(b"\n%_(a~>\n%AI11_EndTextDocument\n", section), Some(7)); + assert_eq!(data_end(b"\n%_(a\n%AI11_EndTextDocument\n", section), Some(6)); + assert_eq!(data_end(b"\n%_(a\n%AI11_BeginTextDocument\n", section), Some(6)); + // Bytes that aren't UTF-8 after the marker, as the scan reads a comment. + assert_eq!(data_end(b"\n%_(a\n%AI11_EndTextDocument \xff\n", section), Some(6)); + // Right after the key a `%` starts no marker, and an empty begin marker matches no comment. + assert_eq!(data_end(b"%AI11_EndTextDocument\n%_(a\n", section), None); + assert_eq!(data_end(b"\n%\n", Some(("", "EndPalette"))), None); + assert_eq!(data_end(b"\n%AI11_EndTextDocument\n", None), None); + } + #[test] fn only_outlines_without_gradients_move_alike() { // GUARDS: an image's pixels and a placed gradient stay behind when an effect moves the outline. diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 30b53a3e2..33e8e10f0 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4583,6 +4583,55 @@ fn data_without_its_end_does_not_hide_the_sections_end() { } } +/// Data without its `~>` is skipped up to the section's end marker: its `%_` line with a `(` opens +/// no string, so the section ends there and the art after it comes in. A `%` right after the key +/// starts no marker, so it doesn't end the data. +#[test] +fn data_without_its_end_holding_a_string_ends_at_the_sections_end() { + let art_layer = layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")); + for document in [ + "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%_(]<[%\n%AI11_EndTextDocument\n", + "%AI11_BeginTextDocument\r\n/AI11TextDocument : /ASCII85Decode ,\r\n%_(]<[%\r\n%AI11_EndTextDocument\r\n", + "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,%AI11_EndTextDocument\n%_(]<[%\n%AI11_EndTextDocument\n", + ] { + let s = read_ok(&format!("{document}{art_layer}")); + assert_eq!(art(&s.doc).len(), 1, "{document}"); + } +} + +/// Data without its `~>` stops at the start of a section of the same kind nested after it too, so +/// the outer section still ends at its own end marker and not at the nested one's. +#[test] +fn data_without_its_end_stops_at_a_nested_sections_start() { + let square = rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"; + let body = format!( + "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n/Binary : /ASCII85Decode ,\n%_(]<[%\n\ + %AI17_Begin_Content_if_version_gt:23 1\nfrobnicate\n%AI17_End_Versioned_Content\n{square}%AI17_End_Versioned_Content\n{}", + layer(&square) + ); + let s = read_ok(&body); + assert_eq!(art(&s.doc).len(), 1); + assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings); +} + +/// Versioned content that doesn't end is scanned again for the swatches' names: data with its `~>` +/// is still skipped, though data with none comes after it, and the dictionary open where the +/// content starts still counts. +#[test] +fn swatch_names_are_found_past_data_in_versioned_content_that_does_not_end() { + let entries = "1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n0 0 Xd\n6 () XW\n0 Ae\n"; + let alternate = "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n"; + let sea = format!("0 1 1 0 (Sea) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let unended = "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%AI11_EndTextDocument\n"; + for art_text in [format!("{alternate}/ForeignObject :\n{entries}{sea}{unended}"), format!("/ForeignObject :\n{alternate}{entries}{sea}{unended}")] + { + let body = format!("{}{}", DOTS.replace("(Dots)", "(Sea\\n1)"), layer(&art_text)); + let d = read(stream(&body).as_bytes()).unwrap().doc; + let names: Vec<&str> = d.patterns.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Sea 2"], "{art_text}"); + } +} + /// The names the swatches take are found past the same data: a named color after it still takes /// its name first, and the pattern named after it is numbered. #[test] From a17907ee7d5c1fd47a11462bd5fb54eedae17b49 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 10:06:16 -0800 Subject: [PATCH 36/92] .ai import: data with no end outside a section does not hide a later section's end When the scan for swatch names searched data outside a section and found no `~>`, it stopped searching for the end of all later data. Data in a later section then was not skipped, so a `(` on a `%_` line of it opened a string that ran past the section's end marker, and a color named after the section did not reserve its name. The scan now records the search that found nothing per section's end marker, so a search in another section still stops at that section's markers. --- crates/eps/src/import/ai/read/ops.rs | 10 ++++++---- crates/eps/src/import/ai/tests.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 5b2f86102..28e95451d 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1143,8 +1143,9 @@ pub(super) struct Dictionaries<'s> { name: Option<&'s [u8]>, /// Whether the string just before the token taken is a key that ASCII85 data follows. string_key: bool, - /// The rest of the data has no `~>` or marker to stop at, so they aren't searched for again. - no_end: bool, + /// The end markers of the sections being scanned (`None`: no section) where the rest of the data + /// had no `~>` or marker to stop at, so they aren't searched for again in those sections. + no_end: Vec>, } /// Where the ASCII85 data at the start of `rest` stops: just past its `~>`, or at the comment that @@ -1176,10 +1177,11 @@ impl<'s> Dictionaries<'s> { /// Take `tok`, the token `lex` just gave, and skip the ASCII85 data that follows it, up to where /// [`data_end`] stops for `section`, so the scan still finds that section's markers. pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, section: Option<(&str, &str)>) { - if self.data_follows(tok) && !self.no_end { + let end = section.map(|(_, end)| end); + if self.data_follows(tok) && !self.no_end.iter().any(|e| e.as_deref() == end) { match data_end(lex.rest(), section) { Some(stop) => lex.seek(lex.offset() + stop), - None => self.no_end = true, + None => self.no_end.push(end.map(str::to_owned)), } } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 33e8e10f0..e6470d84d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4632,6 +4632,25 @@ fn swatch_names_are_found_past_data_in_versioned_content_that_does_not_end() { } } +/// A search for where data stops that found nothing outside a section doesn't stop a later +/// section's data at that section's end, so the named color after it still takes its name first. +/// The `/Binary` holds a group, where the reader doesn't take what follows its key as data. +#[test] +fn swatch_names_are_found_past_data_in_a_section_after_a_search_outside_sections_found_nothing() { + let sea = format!("0 1 1 0 (Sea) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let before = "/Binary :\nu\n/ASCII85Decode ,\nU\n;\n"; + for section in [ + "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n/Binary : /ASCII85Decode ,\n%_(]<[%\n%AI17_End_Versioned_Content\n", + "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%_(]<[%\n%AI11_EndTextDocument\n", + ] { + let art_text = format!("{before}{section}{sea}"); + let body = format!("{}{}", DOTS.replace("(Dots)", "(Sea\\n1)"), layer(&art_text)); + let d = read(stream(&body).as_bytes()).unwrap().doc; + let names: Vec<&str> = d.patterns.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Sea 2"], "{art_text}"); + } +} + /// The names the swatches take are found past the same data: a named color after it still takes /// its name first, and the pattern named after it is numbered. #[test] From a3045845c80998641e225f10d32d75b25ec1b078 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 08:22:02 -0800 Subject: [PATCH 37/92] PDF export: members of a plain group or layer are measured once to leave out off-page art The walk measured every object's visual box to leave out what is off the page. A plain group or layer is measured as the union of its members, and each member then measured itself again when it was written, so an object in plain groups was measured once for each level it sits at. Measuring a plain group or layer now keeps its members' boxes in a tree, and each member uses its box when it is written. A container written whatever its box is (a PDF layer's group, placed symbol art) is not measured. A knockout group's elements are measured for the group's box and again for the list of elements on the page, and that second box is used for the element and for its mask. Objects in a live container (Blend, Envelope, Repeat, Intertwine) are still measured with the container and again after it is expanded. Node::visual_of_members holds the formula that Node::visual also uses, so the boxes and every decision are unchanged. The output is byte for byte the same on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, and 5,000 point type objects, and on a document of plain, see-through, clipping and knockout groups on and off the page, each exported with default settings, with uncompressed text and with PDF layers. --- crates/doc/src/node/ops.rs | 14 +++-- crates/doc/src/node/tests.rs | 14 +++++ crates/pdf/src/export.rs | 2 + crates/pdf/src/export/nodes.rs | 77 +++++++++++++++++++++------ crates/pdf/src/tests.rs | 1 + crates/pdf/src/tests/measuring.rs | 87 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 crates/pdf/src/tests/measuring.rs diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index f31f942ce..69b2c6b68 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -332,6 +332,14 @@ impl Node { self.visual(Some(symbols)) } + /// The visual box of a group or layer without a clipping path, from the visual boxes of its + /// [`Node::editing_children`]: for a walk that measures the members as well. + pub fn visual_of_members(&self, members: impl Iterator>) -> Option { + // The container's own strokes paint around its members. + let o = self.appearance.outset(); + members.fold(None, vectorcraft_geom::union_opt).map(|b| b.inflate(o, o)) + } + /// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is /// given. pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option { @@ -346,11 +354,7 @@ impl Node { } } NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))), - NodeKind::Layer { .. } | NodeKind::Group { .. } => { - // The container's own strokes paint around its members. - let o = self.appearance.outset(); - self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o)) - } + NodeKind::Layer { .. } | NodeKind::Group { .. } => self.visual_of_members(self.editing_children().iter().map(|c| c.visual(symbols))), NodeKind::Blend { children, spec } => { let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))); let o = crate::live::max_outset(children, symbols); diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index cde4004ae..279221a53 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -443,6 +443,20 @@ fn visual_bounds_include_stroke() { assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); } +/// A group's box from its members' boxes grows by the group's own strokes, leaves out members +/// without a box, and is `None` when none has one. +#[test] +fn visual_of_members_unions_the_members_and_grows_by_the_group_stroke() { + let mut g = Node::group(NodeId(1), vec![]); + g.appearance = Appearance::default_art().into(); + let (a, b) = (Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(20.0, 5.0, 30.0, 8.0)); + let o = g.appearance.outset(); + assert!(o > 0.0); + assert_eq!(g.visual_of_members([Some(a), None, Some(b)].into_iter()), Some(Rect::new(0.0, 0.0, 30.0, 10.0).inflate(o, o))); + assert_eq!(g.visual_of_members([None, None].into_iter()), None); + assert_eq!(g.visual_of_members([].into_iter()), None); +} + /// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the /// weight centred, the whole weight outside, nothing inside; scaled with the type. #[test] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index cfb3d9344..8728b29b2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -45,6 +45,8 @@ pub use entry::{export, export_with_report, page_areas}; use exporter::{MAX_COORD, writable, xf}; #[cfg(test)] use images::{png_8_bit, png_16_bit}; +#[cfg(test)] +pub(crate) use nodes::MEASURES; pub(crate) use writer::{Sheet, Writer}; pub(crate) struct Exporter<'a> { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 389552948..50a487ce8 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -4,6 +4,20 @@ use super::{ Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke, }; +/// An object's visual box for leaving out what is off the page. A plain group or layer also holds +/// the boxes of the members it is measured through ([`Node::editing_children`], in order), so that +/// the walk measures them once. +struct Measured { + bounds: Option, + members: Vec, +} + +#[cfg(test)] +std::thread_local! { + /// The objects this thread has measured for the walk (tests). + pub(crate) static MEASURES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + impl Exporter<'_> { /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). @@ -38,9 +52,15 @@ impl Exporter<'_> { /// The children of a group, as the elements of a knockout group when it is one. pub(super) fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect) { + self.measured_children(s, children, page, &[]); + } + + /// [`Self::children`] with the boxes of the last members in `measured`. + fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect, measured: &[Measured]) { if !self.knockout { - for c in children { - self.node(s, c, page, false); + let unmeasured = children.len().saturating_sub(measured.len()); + for (i, c) in children.iter().enumerate() { + self.measured_node(s, c, page, false, i.checked_sub(unmeasured).and_then(|i| measured.get(i))); } return; } @@ -50,23 +70,25 @@ impl Exporter<'_> { self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); let elements: Vec<_> = Node::knockout_elements(children) .into_iter() - .filter(|c| c.visual_bounds_with(&self.symbols).is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) + .map(|c| (c, self.measure(c))) + .filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) .collect(); - let Some((first, rest)) = elements.split_first() else { return }; - for c in rest.iter().rev() { - let mask = self.knockout_mask(s, c, page); + let Some(((first, measured), rest)) = elements.split_first() else { return }; + for (c, m) in rest.iter().rev() { + let mask = self.knockout_mask(s, c, page, m); s.push_mask(mask); } - self.node(s, first, page, false); - for c in rest { + self.measured_node(s, first, page, false, Some(measured)); + for (c, m) in rest { s.pop(); - self.node(s, c, page, false); + self.measured_node(s, c, page, false, Some(m)); } } - /// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha - /// mask of `c` (at full object opacity without its own mask, unless those define its shape). - fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { + /// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white, + /// then black through an alpha mask of `c` (at full object opacity without its own mask, unless + /// those define its shape). + fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect, measured: &Measured) -> krilla::mask::Mask { let shape = if c.knockout_shape { c.clone() } else { @@ -81,7 +103,7 @@ impl Exporter<'_> { let mut ab = ms.stream_builder(); let mut als = ab.surface(); let overprint = std::mem::take(&mut self.overprint); - self.node(&mut als, &shape, page, false); + self.measured_node(&mut als, &shape, page, false, Some(measured)); self.overprint = overprint; als.finish(); krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) @@ -93,7 +115,23 @@ 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. + 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![] }; + } + 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 } + } + pub(super) fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { + self.measured_node(s, n, page, force, None); + } + + /// [`Self::node`] with the box of `n` in `measured`, when its parent measured it. + fn measured_node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool, measured: Option<&Measured>) { if self.layers && self.in_layer != address(n) && let NodeKind::Layer { printable, .. } = n.kind @@ -115,9 +153,12 @@ impl Exporter<'_> { { return; } - match n.visual_bounds_with(&self.symbols) { - Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return, - None if !n.is_container() => return, + // A container written whatever its box is has no use for one. + let own = (measured.is_none() && !(force && n.is_container())).then(|| self.measure(n)); + let measured = measured.or(own.as_ref()); + match measured.map(|m| m.bounds) { + Some(Some(b)) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return, + Some(None) if !n.is_container() => return, _ => {} } self.warn_raster(&n.appearance.effects, || format!("{} objects", n.kind_label().to_lowercase())); @@ -161,7 +202,9 @@ impl Exporter<'_> { pushes += 1; } match &n.kind { - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice())); + } NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // The region every output clips to; nothing to clip by hides the clipped art. The // clipping path's fill paints behind the clipped art and its stroke over it, unclipped. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f8df38500..eb9a4d284 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -30,6 +30,7 @@ mod layers; mod links; pub(super) mod live_text; mod marks; +mod measuring; mod mesh_export; mod output; mod pdf13; diff --git a/crates/pdf/src/tests/measuring.rs b/crates/pdf/src/tests/measuring.rs new file mode 100644 index 000000000..90efd4405 --- /dev/null +++ b/crates/pdf/src/tests/measuring.rs @@ -0,0 +1,87 @@ +use std::sync::Arc; + +use vectorcraft_doc::{Knockout, Node, NodeId}; + +use super::*; + +/// The objects measured to leave out what is off the page while `d` is exported (on this thread). +fn measures(d: &Document) -> usize { + crate::export::MEASURES.with(|m| m.set(0)); + export(d, &PdfOptions { created: Some(0), ..PdfOptions::default() }).expect("export"); + crate::export::MEASURES.with(std::cell::Cell::get) +} + +fn square(x: f64) -> Arc { + Arc::new(rect_node(Rect::new(x, 10.0, x + 20.0, 30.0), Color::rgb(0.2, 0.4, 0.6))) +} + +/// An object in groups within a layer is measured once in an export, as an object directly in a +/// layer is. +#[test] +fn an_object_in_nested_groups_is_measured_once() { + let mut d = doc(200.0, 200.0); + add(&mut d, (*square(10.0)).clone()); + assert_eq!(measures(&d), 1); + + let mut d = doc(200.0, 200.0); + let inner = Node::group(NodeId(0), vec![square(10.0), square(40.0)]); + add(&mut d, Node::group(NodeId(0), vec![Arc::new(inner), square(70.0)])); + assert_eq!(measures(&d), 3); +} + +/// A group in PDF layers, where a layer's box is not used, is measured once as well. +#[test] +fn an_object_in_a_pdf_layer_is_measured_once() { + let mut d = doc(200.0, 200.0); + add(&mut d, Node::group(NodeId(0), vec![square(10.0), square(40.0)])); + let mut opts = PdfOptions { created: Some(0), ..PdfOptions::default() }; + opts.settings.create_layers = true; + crate::export::MEASURES.with(|m| m.set(0)); + export(&d, &opts).expect("export"); + assert_eq!(crate::export::MEASURES.with(std::cell::Cell::get), 2); +} + +/// The elements of a knockout group are measured for the group's box and to choose the elements +/// on the page, and not again when each is drawn and when its mask is made. +#[test] +fn an_element_of_a_knockout_group_is_measured_twice() { + let mut d = doc(200.0, 200.0); + let mut group = Node::group(NodeId(0), vec![square(10.0), square(20.0)]); + group.knockout = Knockout::On; + add(&mut d, group); + assert_eq!(measures(&d), 4); +} + +/// A Shaper group's first member is left out of the group's box, and the boxes found by measuring +/// the group belong to the members after it. An off-page first member is not written, and the +/// member on the page is. +#[test] +fn a_member_left_out_of_the_group_box_measures_itself() { + let on_page = square(10.0); + let off_page = square(5000.0); + + let mut shaped = doc(200.0, 200.0); + let mut group = Node::group(NodeId(0), vec![off_page, on_page.clone()]); + group.extras_mut().shaper = Some(Box::default()); + add(&mut shaped, group); + + let mut plain = doc(200.0, 200.0); + add(&mut plain, Node::group(NodeId(0), vec![on_page])); + + assert_eq!(uncompressed(&shaped), uncompressed(&plain)); +} + +/// A group wholly off the page is not written, with the see-through empty group inside it. +#[test] +fn a_group_off_the_page_writes_nothing() { + let mut empty = Node::group(NodeId(0), vec![]); + empty.opacity = 0.5; + let mut off = doc(200.0, 200.0); + add(&mut off, (*square(10.0)).clone()); + add(&mut off, Node::group(NodeId(0), vec![square(5000.0), Arc::new(empty)])); + + let mut none = doc(200.0, 200.0); + add(&mut none, (*square(10.0)).clone()); + + assert_eq!(uncompressed(&off), uncompressed(&none)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3da7b29cf..7a48cefbe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1176,6 +1176,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A font family removed while the app runs leaves the font menus when the app scans the font folders again (when its window comes to the front after a font folder changed, or Refresh Font List), also when type had used it: that type then draws in the fallback font and Find Font lists the font as missing, until the font is installed again and the folders are scanned. - **Files (M4):** - PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source. + PDF export measures each member of a plain group or layer once to leave out what is off the page: the group or layer is measured through its members (`Node::visual_of_members`), and each member uses that box when it is written. The elements of a knockout group are measured twice, once for the group's box and once to choose the elements on the page (`pdf/src/tests/measuring.rs` counts the measures). A local byte comparison with the previous build found identical output on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, 5,000 point type objects and a document of plain, see-through, clipping and knockout groups. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). From b50a6bcad449aa44a585811d7b720502888bd67a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 09:10:33 -0800 Subject: [PATCH 38/92] PDF export: the link walk measures only linked objects and clipping groups The walk that writes link annotations measured every object at every level, on every export that can have links, and read the box only for an object with a URL that opens and for a clipping group. It now measures only those, so a document with no URLs or clipping groups is not measured a second time. The link annotations are unchanged. The output is byte for byte the same on a document of linked objects in plain and clipping groups on and off the page, with default settings, with uncompressed text and with PDF layers. --- crates/pdf/src/export/links.rs | 13 +++++++++-- crates/pdf/src/export/nodes.rs | 2 +- crates/pdf/src/tests/measuring.rs | 36 ++++++++++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/export/links.rs b/crates/pdf/src/export/links.rs index 5eec2fd83..a40ca6692 100644 --- a/crates/pdf/src/export/links.rs +++ b/crates/pdf/src/export/links.rs @@ -47,8 +47,10 @@ impl Exporter<'_> { { return; } - let bounds = n.visual_bounds_with(&self.symbols).filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())); - if let (Some(uri), Some(b)) = (n.url().filter(|u| opens(u)), bounds) { + // Only a link and a clipping group read the box. + let uri = n.url().filter(|u| opens(u)); + let bounds = (uri.is_some() || n.clips()).then(|| self.measure_for_links(n)).flatten(); + if let (Some(uri), Some(b)) = (uri, bounds) { let shown = b.intersect(within); if shown.width() > 0.0 && shown.height() > 0.0 { let rect = to_page.transform_rect_bbox(shown); @@ -74,6 +76,13 @@ impl Exporter<'_> { self.link_node(child, within, to_page, links, more); } } + + /// The visual box of `n`, when all its coordinates are finite. + fn measure_for_links(&self, n: &Node) -> Option { + #[cfg(test)] + super::MEASURES.with(|m| m.set(m.get() + 1)); + n.visual_bounds_with(&self.symbols).filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) + } } /// `uri` as a PDF URI (ISO 32000-1 §12.6.4.7: 7-bit ASCII): spaces, control characters and diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 50a487ce8..e8057236c 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -14,7 +14,7 @@ struct Measured { #[cfg(test)] std::thread_local! { - /// The objects this thread has measured for the walk (tests). + /// The objects this thread has measured for the walk and for the links (tests). pub(crate) static MEASURES: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/pdf/src/tests/measuring.rs b/crates/pdf/src/tests/measuring.rs index 90efd4405..1ed6af95e 100644 --- a/crates/pdf/src/tests/measuring.rs +++ b/crates/pdf/src/tests/measuring.rs @@ -4,7 +4,8 @@ use vectorcraft_doc::{Knockout, Node, NodeId}; use super::*; -/// The objects measured to leave out what is off the page while `d` is exported (on this thread). +/// The objects measured to leave out what is off the page and to write links while `d` is exported +/// (on this thread). fn measures(d: &Document) -> usize { crate::export::MEASURES.with(|m| m.set(0)); export(d, &PdfOptions { created: Some(0), ..PdfOptions::default() }).expect("export"); @@ -52,6 +53,39 @@ fn an_element_of_a_knockout_group_is_measured_twice() { assert_eq!(measures(&d), 4); } +/// The links of a tree with no URLs and no clipping group are found without measuring: the export +/// measures each object once, as the walk that leaves out what is off the page does. +#[test] +fn objects_without_a_url_are_not_measured_for_links() { + let mut d = doc(200.0, 200.0); + let inner = Node::group(NodeId(0), vec![square(10.0), square(40.0)]); + add(&mut d, Node::group(NodeId(0), vec![Arc::new(inner)])); + assert_eq!(measures(&d), 2); +} + +/// Only an object with a URL that opens is measured for its link, and not the groups around it. +#[test] +fn only_an_object_with_a_url_is_measured_for_its_link() { + let mut linked = (*square(10.0)).clone(); + linked.edit_attrs(|a| a.url = "https://example.com".into()); + let mut refused = (*square(40.0)).clone(); + refused.edit_attrs(|a| a.url = "javascript:alert(1)".into()); + let mut d = doc(200.0, 200.0); + add(&mut d, Node::group(NodeId(0), vec![Arc::new(linked), Arc::new(refused)])); + assert_eq!(measures(&d), 3); +} + +/// A clipping group is measured for the links inside it, and what it holds is not: the group for +/// the page and for the links, then the clipping path's fill and the member when they are written. +#[test] +fn a_clipping_group_is_measured_for_the_links_inside_it() { + let mut clip = Node::group(NodeId(0), vec![square(10.0), square(15.0)]); + clip.set_clips(true); + let mut d = doc(200.0, 200.0); + add(&mut d, clip); + assert_eq!(measures(&d), 4); +} + /// A Shaper group's first member is left out of the group's box, and the boxes found by measuring /// the group belong to the members after it. An off-page first member is not written, and the /// member on the page is. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7a48cefbe..e0ea84890 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1176,7 +1176,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A font family removed while the app runs leaves the font menus when the app scans the font folders again (when its window comes to the front after a font folder changed, or Refresh Font List), also when type had used it: that type then draws in the fallback font and Find Font lists the font as missing, until the font is installed again and the folders are scanned. - **Files (M4):** - PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source. - PDF export measures each member of a plain group or layer once to leave out what is off the page: the group or layer is measured through its members (`Node::visual_of_members`), and each member uses that box when it is written. The elements of a knockout group are measured twice, once for the group's box and once to choose the elements on the page (`pdf/src/tests/measuring.rs` counts the measures). A local byte comparison with the previous build found identical output on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, 5,000 point type objects and a document of plain, see-through, clipping and knockout groups. + PDF export measures each member of a plain group or layer once to leave out what is off the page: the group or layer is measured through its members (`Node::visual_of_members`), and each member uses that box when it is written. The elements of a knockout group are measured twice, once for the group's box and once to choose the elements on the page (`pdf/src/tests/measuring.rs` counts the measures). A local byte comparison with the previous build found identical output on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, 5,000 point type objects and a document of plain, see-through, clipping and knockout groups. The walk that writes link annotations measures only an object with a URL that opens and a clipping group (`links.rs`), and its output on a document of linked objects in plain and clipping groups is the same byte for byte as when it measured every object. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). From ab3ac3d4296b6bda457a97c247734ac22406174d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 08:34:23 -0800 Subject: [PATCH 39/92] .ai import: envelopes map from their stroked box when the file saves its size When Distort Appearance is on and the size a `.ai` saves for an envelope's contents (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`) matches the contents' bounds with strokes, the reader now maps the contents from that stroked box. It used to map them from the geometric bounds, half a stroke inside that box, so an envelope of stroked art could differ from its drawn art and keep the drawn art with a note. Envelope Options hold the box's margins as `boundsMargin`, shares of the geometric bounds at the top, bottom, left and right. Native files write them only when they are not zero, and `object.envelope.info` reports them then. Leaving Edit Contents measures them again when the strokes reach differently than when Edit Contents began, and the node holds what they measured then until it leaves. New envelopes have no margins, so their results do not change. Contents with no width or no height, such as one straight line, get no margins. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/live.rs | 62 +++++++++++- crates/doc/src/live/envelope.rs | 62 ++++++++++-- crates/doc/src/node/extras.rs | 4 + crates/doc/src/node/ops.rs | 2 +- crates/doc/src/node/tests.rs | 2 + crates/doc/src/node/wire.rs | 5 + crates/engine/src/cmd/live/commands.rs | 6 +- crates/engine/src/cmd/live/envelope.rs | 30 +++++- crates/engine/src/tests/envelope.rs | 125 +++++++++++++++++++++++ crates/engine/tests/import_fuzz.rs | 7 +- crates/eps/src/import/ai/envelope.rs | 38 ++++++- crates/eps/src/import/ai/tests.rs | 132 ++++++++++++++++++++++++- docs/mcp.md | 7 ++ docs/target-app-parity.md | 2 +- 14 files changed, 459 insertions(+), 25 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 9a2aa4fea..40a1c78b0 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -53,8 +53,8 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, #[cfg(test)] use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ - EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_expand, envelope_frame, - envelope_grid, envelope_mapper, envelope_of, envelope_overlay, smooth_handles, + EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_content_boxes, + envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles, }; pub(crate) use expand::max_outset; pub use expand::{ @@ -233,6 +233,64 @@ mod tests { assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p); } + /// `bounds_margin` grows the box an envelope maps from, only while Distort Appearance is on. It + /// holds shares of the box, and an envelope with an uneven frame maps from the same box in that + /// frame. A native file writes it only when it isn't zero. + #[test] + fn an_envelope_maps_from_its_box_with_its_margins() { + let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art()); + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let content = [Arc::new(rect)]; + let src = |content: &[Arc], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src(); + // Top, bottom, left and right. + let on = EnvelopeOptions { bounds_margin: [0.125, 0.25, 0.0625, 0.5], ..EnvelopeOptions::NEW }; + let geometric = Rect::new(10.0, 20.0, 110.0, 70.0); + let grown = Rect::new(3.75, 13.75, 160.0, 82.5); + assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions::default()), geometric); + assert_eq!(src(&content, Affine::IDENTITY, on), grown); + assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { distort_appearance: false, ..on }), geometric); + for bad in [f32::NAN, f32::INFINITY, -0.05] { + assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { bounds_margin: [bad, 0.25, 0.0625, 0.5], ..on }), geometric); + } + // A finite share of a box too wide to grow leaves the box as it is. + let wide = Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 1e308, 10.0)), crate::Appearance::default_art()); + let wide = [Arc::new(wide)]; + let huge = EnvelopeOptions { bounds_margin: [0.0, 0.0, f32::MAX, 0.0], ..on }; + assert_eq!(src(&wide, Affine::IDENTITY, huge), src(&wide, Affine::IDENTITY, EnvelopeOptions::NEW)); + // Bounds follow the box: a warp that doesn't bend covers it. + let b = envelope_bounds(&content, &kind, Affine::IDENTITY, on).unwrap(); + assert!((b.x0 - 3.75).abs() < 1e-6 && (b.y1 - 82.5).abs() < 1e-6, "{b:?}"); + // Art taken through an uneven frame maps from the same box in that frame. + let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(2.0, 0.5); + let mut placed = (*content[0]).clone(); + placed.transform(frame, true); + let framed = src(&[Arc::new(placed)], frame, on); + assert!((framed.x0 - 3.75).abs() + (framed.y0 - 13.75).abs() + (framed.x1 - 160.0).abs() + (framed.y1 - 82.5).abs() < 1e-6, "{framed:?}"); + + // A frame that turns and scales gives the same margins as the art had before it took them. + let mut stroked = (*content[0]).clone(); + if let Some(s) = stroked.appearance.stroke_mut() { + s.width = 10.0; + } + let margin = |n: &Node, frame: Affine| { + let (with, plain) = envelope_content_boxes(&[Arc::new(n.clone())], frame).unwrap(); + envelope_stroke_margin(with, plain).unwrap() + }; + let before = margin(&stroked, Affine::IDENTITY); + assert_eq!(before.map(|m| (m * 1000.0).round()), [100.0, 100.0, 50.0, 50.0]); + let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale(2.0); + let mut turned = stroked.clone(); + turned.transform(frame, true); + assert_eq!(margin(&turned, frame).map(|m| (m * 1000.0).round()), before.map(|m| (m * 1000.0).round())); + // Margins an `f32` can't hold are none. + assert_eq!(envelope_stroke_margin(Rect::new(-5e41, 0.0, 100.0, 100.0), Rect::new(0.0, 0.0, 100.0, 100.0)), None); + + assert!(serde_json::to_value(EnvelopeOptions::default()).unwrap().get("boundsMargin").is_none()); + let saved = serde_json::to_value(on).unwrap(); + assert_eq!(saved["boundsMargin"], serde_json::json!([0.125, 0.25, 0.0625, 0.5])); + assert_eq!(serde_json::from_value::(saved).unwrap(), on); + } + /// How far the border of `m` strays from the ellipse in `r`, in points (measured across the /// ellipse's short radius), checked at 20 places along every border edge. fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 { diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 6604c86d1..78e1a5bf0 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -76,6 +76,12 @@ pub struct EnvelopeOptions { pub distort_linear_gradients: bool, /// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art. pub distort_pattern_fills: bool, + /// With Distort Appearance, how far the box the content maps from reaches past its geometric + /// bounds at the top, bottom, left and right, as shares of their height and width. An envelope + /// opened from a `.ai` whose saved size includes its strokes has these margins. New envelopes + /// have none. Leaving Edit Contents measures them again when the strokes reach differently. + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub bounds_margin: [f32; 4], } impl Default for EnvelopeOptions { @@ -86,6 +92,7 @@ impl Default for EnvelopeOptions { distort_appearance: false, distort_linear_gradients: false, distort_pattern_fills: false, + bounds_margin: [0.0; 4], } } } @@ -98,6 +105,7 @@ impl EnvelopeOptions { distort_appearance: true, distort_linear_gradients: false, distort_pattern_fills: false, + bounds_margin: [0.0; 4], }; /// Do gradients bend with the art? pub fn gradients(&self) -> bool { @@ -107,6 +115,45 @@ impl EnvelopeOptions { pub fn patterns(&self) -> bool { self.distort_appearance && self.distort_pattern_fills } + /// The box content with geometric bounds `r` maps from. Margins that aren't finite, are + /// negative or give a box that isn't finite count as none. + fn source_box(&self, r: Rect) -> Rect { + let [top, bottom, left, right] = self.bounds_margin.map(f64::from); + if !self.distort_appearance || !self.bounds_margin.iter().all(|m| m.is_finite() && *m >= 0.0) { + return r; + } + let grown = crate::marks::outset(r, [top * r.height(), bottom * r.height(), left * r.width(), right * r.width()]); + if grown.is_finite() { grown } else { r } + } +} + +/// The bounds of `content` in the axes of `frame`, with its strokes and geometric. Strokes scale +/// with the frame as [`Node::transform`] scales them. +pub fn envelope_content_boxes(content: &[Arc], frame: Affine) -> Option<(Rect, Rect)> { + let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; + let to_frame = frame.inverse(); + let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| { + let mut m = (**c).clone(); + m.transform(to_frame, true); + (vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds())) + }); + Some((stroked?, plain?)) +} + +/// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for +/// content with no width or no height, or for margins an `f32` can't hold. +pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> { + if plain.width() < 1e-6 || plain.height() < 1e-6 { + return None; + } + let margin = [ + (plain.y0 - stroked.y0) / plain.height(), + (stroked.y1 - plain.y1) / plain.height(), + (plain.x0 - stroked.x0) / plain.width(), + (stroked.x1 - plain.x1) / plain.width(), + ] + .map(|m| m as f32); + margin.iter().all(|m| m.is_finite()).then_some(margin) } /// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row. @@ -218,9 +265,9 @@ pub struct EnvelopeMap<'a> { } impl<'a> EnvelopeMap<'a> { - /// The map of an envelope of `kind` around `content` with `frame`. `None` when the content - /// has no bounds. - pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine) -> Option { + /// The map of an envelope of `kind` around `content` with `frame` and `options`. `None` when + /// the content has no bounds. + pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); let src = if frame == Affine::IDENTITY { @@ -232,13 +279,14 @@ impl<'a> EnvelopeMap<'a> { Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds())) })?? }; + let src = options.source_box(src); Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) } /// The map of envelope node `n` (`None` for other nodes). pub fn of(n: &'a Node) -> Option { match &n.kind { - NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame), + NodeKind::Envelope { content, kind, frame, options, .. } => Self::new(content, kind, **frame, *options), _ => None, } } @@ -302,7 +350,7 @@ pub(super) fn expand_envelope( frame: Affine, hooks: Hooks, ) -> Vec { - let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() }; + let Some(map) = EnvelopeMap::new(content, kind, frame, options) else { return content.iter().map(|c| (**c).clone()).collect() }; let src = map.src(); let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 }; let f = |p: Point| map.map(p); @@ -319,7 +367,7 @@ pub(super) fn expand_envelope( } /// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled). -pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine) -> Option { +pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option { match kind { EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(), EnvelopeKind::Mesh { points, .. } => { @@ -327,6 +375,6 @@ pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p))) } EnvelopeKind::TopObject { path } => path.bounds(), - EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(), + EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(), } } diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 239dce93c..90b2726d3 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -66,6 +66,9 @@ pub struct NodeExtras { /// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's /// art (see [`crate::symbol_overrides`]); empty for none. pub symbol_overrides: Vec, + /// As an envelope with margins in Edit Contents, the margins its strokes gave when Edit Contents + /// began ([`crate::live::EnvelopeOptions::bounds_margin`]). + pub envelope_edit_margin: Option<[f32; 4]>, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -91,6 +94,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { compound_shape: false, shape_mode: crate::ShapeMode::Add, symbol_overrides: Vec::new(), + envelope_edit_margin: None, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 69b2c6b68..2b2bef296 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -300,7 +300,7 @@ impl Node { self.projected(Some(xf.transform_rect_bbox(art))) } NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())), - NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame), + NodeKind::Envelope { content, kind, frame, options, .. } => crate::live::envelope_bounds(content, kind, **frame, *options), NodeKind::Mesh(m) => m.bounds(), // Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the // copies out by the source's bounds measured without the document. diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 279221a53..ee29f75eb 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -21,6 +21,7 @@ fn extras_are_written_flat_in_order() { e.live_paint_gaps = Some(Box::default()); e.compound_shape = true; e.shape_mode = crate::ShapeMode::Exclude; + e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]); } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -65,6 +66,7 @@ fn extras_are_written_flat_in_order() { "live_paint_gaps", "compound_shape", "shape_mode", + "envelope_edit_margin", ]; assert_eq!(keys, order); assert_eq!(v["shape_mode"], "exclude"); diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index ed81b1391..32191f58c 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -53,6 +53,7 @@ impl Serialize for Node { is_default(&e.compound_shape), is_default(&e.shape_mode), e.symbol_overrides.is_empty(), + e.envelope_edit_margin.is_none(), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -94,6 +95,7 @@ impl Serialize for Node { field!("compound_shape", &e.compound_shape); field!("shape_mode", &e.shape_mode); field!("symbol_overrides", &e.symbol_overrides); + field!("envelope_edit_margin", &e.envelope_edit_margin); st.end() } } @@ -232,6 +234,8 @@ pub(super) struct NodeWire { shape_mode: crate::ShapeMode, #[serde(default, deserialize_with = "counted_list")] symbol_overrides: Vec, + #[serde(default)] + envelope_edit_margin: Option<[f32; 4]>, } impl From for Node { @@ -258,6 +262,7 @@ impl From for Node { compound_shape: w.compound_shape, shape_mode: w.shape_mode, symbol_overrides: w.symbol_overrides, + envelope_edit_margin: w.envelope_edit_margin, }; Node { id: w.id, diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index abd88c377..20dd94fb3 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -181,7 +181,7 @@ pub fn specs() -> Vec { "Envelope Info", [], None, - "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, editing}", + "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", has_doc, env_info ), @@ -199,7 +199,7 @@ pub fn specs() -> Vec { "Envelope Options…", ["Object", "Envelope Distort"], None, - "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", + "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", has_doc, env_options ), @@ -217,7 +217,7 @@ pub fn specs() -> Vec { "Edit Contents", ["Object", "Envelope Distort"], None, - "{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}", + "{editing?: bool} toggle Edit Contents / Edit Envelope; leaving Edit Contents measures an envelope's boundsMargin again when the content's strokes reach differently than when Edit Contents began → {editing}", has_envelope, env_edit_contents ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index e247530c1..502ab0a7d 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -177,6 +177,9 @@ fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Va v["distortAppearance"] = json!(o.distort_appearance); v["distortLinearGradients"] = json!(o.distort_linear_gradients); v["distortPatternFills"] = json!(o.distort_pattern_fills); + if o.bounds_margin != [0.0; 4] { + v["boundsMargin"] = json!(o.bounds_margin); + } v } @@ -192,6 +195,7 @@ fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result Result { Ok(ids_json(&ids)) } +/// The margins the strokes of `content` give in the axes of `frame` (none for content without +/// width or height). +fn stroke_margin(content: &[Arc], frame: Affine) -> [f32; 4] { + live::envelope_content_boxes(content, frame).and_then(|(stroked, plain)| live::envelope_stroke_margin(stroked, plain)).unwrap_or_default() +} + pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.editContents"; let envs = selected_of(s, is_envelope); @@ -377,12 +387,26 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result { s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| { let mut content_ids = vec![]; for e in &envs { - if let Some(n) = d.node_mut(*e) - && let NodeKind::Envelope { editing, content, .. } = &mut n.kind - { + let Some(n) = d.node_mut(*e) else { continue }; + let start = n.extras().envelope_edit_margin; + let mut began = None; + if let NodeKind::Envelope { editing, content, options, frame, .. } = &mut n.kind { + // Margins stay when the strokes reach as far as when Edit Contents began, whatever + // transforms the envelope took before that. + if options.bounds_margin != [0.0; 4] { + let now = stroke_margin(content, **frame); + if want { + began = start.filter(|_| *editing).or(Some(now)); + } else if *editing && start != Some(now) { + options.bounds_margin = now; + } + } *editing = want; content_ids.extend(content.iter().map(|c| c.id)); } + if began != start { + n.extras_mut().envelope_edit_margin = began; + } } if want { sel.set(content_ids); diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index 0180acb31..8e3cbf6cc 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -221,6 +221,131 @@ fn envelope_options_round_trip_natively_and_old_files_load() { assert!(serde_json::to_value(&legacy).unwrap()["kind"].get("options").is_none()); } +/// Envelope Options change the options they name: the margins an envelope opens with from a file +/// stay, and they survive a native save. +#[test] +fn envelope_options_keep_the_margins_of_an_opened_file() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1})); + assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]); + let margin = [0.01, 0.02, 0.01, 0.02]; + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) { + options.bounds_margin = margin; + } + s.execute("object.envelope.options", &json!({"antiAlias": false, "fidelity": 75})).unwrap(); + assert_eq!(options(&s, e).0.bounds_margin, margin); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(back.node(e), d.node(e)); + assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["boundsMargin"], json!(margin)); + s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap(); + assert_eq!(options(&s, e).0.bounds_margin, margin); +} + +/// `object.envelope.info` leaves out margins that are zero. +#[test] +fn envelope_info_reports_margins_only_when_it_has_them() { + let mut s = session(); + enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1})); + assert!(s.execute("object.envelope.info", &json!({})).unwrap().get("boundsMargin").is_none()); +} + +/// Leaving Edit Contents measures the margins again when a stroke changed: a wider stroke gives +/// larger ones and no stroke gives none, after which a stroke adds none, as in a new envelope. A +/// new envelope's margins stay zero. +#[test] +fn leaving_edit_contents_measures_the_margins_again() { + let mut s = session(); + let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1})); + let set_stroke = |s: &mut Session, weight: f64| { + sel(s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + sel(s, &[a]); + s.execute("stroke.set", &json!({"weight": weight})).unwrap(); + sel(s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + }; + set_stroke(&mut s, 10.0); + assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]); + + let margin = [0.05, 0.05, 0.025, 0.025]; + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) { + options.bounds_margin = [0.5; 4]; + } + set_stroke(&mut s, 20.0); + assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0)); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0)); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0)); + set_stroke(&mut s, 10.0); + assert_eq!(options(&s, e).0.bounds_margin, margin); + // A stroke change, then a second entry, undone back to the first: leaving measures the change. + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + sel(&mut s, &[a]); + s.execute("stroke.set", &json!({"weight": 30.0})).unwrap(); + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 3.0)); + set_stroke(&mut s, 0.0); + assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]); + set_stroke(&mut s, 10.0); + assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]); +} + +/// After an uneven scale, Edit Contents without a stroke change leaves the margins and the box the +/// content maps from, and a stroke change measures them. +#[test] +fn edit_contents_keeps_the_margins_of_a_scaled_envelope_until_a_stroke_changes() { + let mut s = session(); + let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1})); + sel(&mut s, &[a]); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + s.execute("stroke.set", &json!({"weight": 10.0})).unwrap(); + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + let margin = [0.05, 0.05, 0.025, 0.025]; + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) { + options.bounds_margin = margin; + } + s.execute("object.transform", &json!({"ids": [e.0], "matrix": [2, 0, 0, 0.5, 0, 0]})).unwrap(); + let src = |s: &Session| live::EnvelopeMap::of(s.doc().unwrap().doc.node(e).unwrap()).unwrap().src(); + let box_before = src(&s); + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + assert_eq!(options(&s, e).0.bounds_margin, margin); + assert_eq!(src(&s), box_before); + // Undoing that step and leaving again gives the same. + s.execute("edit.undo", &json!({})).unwrap(); + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + assert_eq!(options(&s, e).0.bounds_margin, margin); + // So does saving natively in Edit Contents, reopening and leaving. + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(back.node(e), s.doc().unwrap().doc.node(e)); + assert!(back.node(e).unwrap().extras().envelope_edit_margin.is_some()); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + assert!(node(&s, e).extras().envelope_edit_margin.is_none()); + + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + sel(&mut s, &[a]); + s.execute("stroke.set", &json!({"weight": 0.0})).unwrap(); + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]); +} + /// Regression: every exporter wrote type inside an envelope undistorted (no text outliner), so a /// warped line of type exported the same whatever its bend. #[test] diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d4d545b9e..8c3b2add7 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3219,6 +3219,9 @@ proptest! { fidelity in prop::sample::select(vec!["50", "0", "100", "101", "-1", "1e308", "nan"]), size in prop::sample::select(vec!["[1 1]", "[3 2]", "[0 0]", "[51 1]", "[100000 100000]", "[-1 2]", "[1e308 1]"]), corner in prop::sample::select(vec!["10 10", "1e308 -1e308", "nan 0", "10 10 10 10", "0"]), + art_width in prop::sample::select(vec!["81", "80", "0", "-5", "1e308", "nan"]), + art_height in prop::sample::select(vec!["81", "80", "0", "-5", "1e308", "nan"]), + weight in prop::sample::select(vec!["1", "0", "1e308", "-1"]), edit in 0i64..2, cut in 0usize..2_000, edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', ':', ';', ',', '[', ']', '%', '_', 'X', 'u', 'U'])), 0..8), @@ -3231,9 +3234,9 @@ proptest! { n("10", "90"), n("90", "90") ); - let content = "%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_f\n%_U\n"; + let content = format!("%_u\n%_1 0 0 1 0 0 Xa\n%_0 1 1 0 1 0 0 XA\n%_{weight} w\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_b\n%_U\n"); let keys = format!( - "%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n" + "%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n%_{art_width} /Real (AIDeformArtWidthKey) ,\n%_{art_height} /Real (AIDeformArtHeightKey) ,\n" ); let art = format!( "%_u\n{mesh}%_U\n%_/ArtDictionary :\n{keys}%_X=\n{content}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\nu\n1 0 0 1 0 0 Xa\n12 12 m\n12 88 L\n88 88 L\nf\nU\n(Adobe Deform Plugin) 1 0 0 XP\n0 0 1 1 0 0 Xa\n0 0 m\n5 5 L\nf\n" diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 33fae9eab..d9f392215 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -14,7 +14,9 @@ //! then its `/ArtDictionary`: Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity //! (`AIDeformFidelityKey`), Edit Contents (`AIDeformEditModeKey`), a warp's style (`DeformStyle`, //! numbered as Effect › Warp lists them), bend and distortions (`DeformValue`, `DeformHoriz`, -//! `DeformVert`, fractions) and orientation (`Rotate`), the contents (`X=` … `X+`, then +//! `DeformVert`, fractions) and orientation (`Rotate`), the size of the contents, read as the size +//! of the box they map from with Distort Appearance on when it matches their bounds with strokes +//! (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the contents (`X=` … `X+`, then //! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the //! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group //! holds the contents and the dictionary the mesh. A top object's outline is the edge of its @@ -24,8 +26,11 @@ use std::collections::BTreeMap; use std::sync::Arc; -use kurbo::{BezPath, Point, Rect, Vec2}; -use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity}; +use kurbo::{Affine, BezPath, Point, Rect, Vec2}; +use vectorcraft_doc::live::{ + EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity, envelope_content_boxes, + envelope_stroke_margin, +}; use vectorcraft_doc::{Document, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef}; use vectorcraft_geom::PathData; @@ -42,6 +47,9 @@ pub(super) const MAX_PENDING: usize = 64; pub(super) const MAX_CHECKED: usize = 256; /// The most rows or columns of a mesh read (Make with Mesh's own limit). const MAX_GRID: u32 = 50; +/// How far, in points over width and height together, a saved size may lie from the contents' +/// bounds with their strokes and still count as that box. +const SAVED_SIZE_SLACK: f64 = 0.5; /// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort /// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask). const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17; @@ -88,10 +96,29 @@ pub(super) fn options(o: &Obj) -> Option<(EnvelopeOptions, f64)> { distort_appearance: bit(0), distort_pattern_fills: bit(1), distort_linear_gradients: bit(2), + bounds_margin: [0.0; 4], }; Some((options, fidelity)) } +/// The margins of `content` when the size the file saves for it (`AIDeformArtWidthKey`, +/// `AIDeformArtHeightKey`) matches its bounds with its strokes within [`SAVED_SIZE_SLACK`] and lies +/// nearer them than its geometric bounds. None without that size, or for contents with no width or +/// no height. +fn saved_margin(o: &Obj, content: &[Arc]) -> Option<[f32; 4]> { + let saved = |key: &str| match o.nums(key).as_slice() { + [v] if v.is_finite() => Some(*v), + _ => None, + }; + let (w, h) = (saved("AIDeformArtWidthKey")?, saved("AIDeformArtHeightKey")?); + let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; + let off = |r: Rect| (r.width() - w).abs() + (r.height() - h).abs(); + if off(stroked) > SAVED_SIZE_SLACK || off(stroked) >= off(plain) { + return None; + } + envelope_stroke_margin(stroked, plain) +} + /// A warp: its style, bend, distortions and orientation. fn warp(o: &Obj) -> Option { let style = whole(o, "DeformStyle")??; @@ -232,7 +259,7 @@ fn unwrapped(mut nodes: Vec) -> Vec> { /// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one. pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option { let editing = switch(o, "AIDeformEditModeKey")?; - let (options, fidelity) = options(o)?; + let (mut options, fidelity) = options(o)?; let before = before?; // While the contents are edited, they are the group before the dictionary and the mesh is in it. let (content, shape): (Vec>, Option<&GradientMesh>) = if editing { @@ -249,6 +276,9 @@ pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option warp(o)?, 2 => { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index e6470d84d..b36bfba13 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5883,8 +5883,14 @@ pub(super) fn envelope_content() -> String { /// side, mapped as VectorCraft maps them, as a red path in art space. pub(super) fn drawn_content(kind: &vectorcraft_doc::live::EnvelopeKind) -> String { let rect = Rect::new(40.0, 20.0, 160.0, 80.0); - let map = vectorcraft_doc::live::envelope_mapper(kind, rect); - let corners = [Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(160.0, 80.0), Point::new(40.0, 80.0)]; + drawn_box(kind, rect, rect) +} + +/// What envelope `kind`, mapping from box `src`, draws of a red rectangle `rect`, as in +/// [`drawn_content`]. +fn drawn_box(kind: &vectorcraft_doc::live::EnvelopeKind, src: Rect, rect: Rect) -> String { + let map = vectorcraft_doc::live::envelope_mapper(kind, src); + let corners = [Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]; let mut s = RED.to_string(); for (k, a) in corners.iter().enumerate() { let b = corners[(k + 1) % 4]; @@ -6090,6 +6096,7 @@ fn an_envelope_reads_its_options_and_fidelity() { distort_appearance: false, distort_linear_gradients: false, distort_pattern_fills: false, + bounds_margin: [0.0; 4], }, ), ( @@ -6101,6 +6108,7 @@ fn an_envelope_reads_its_options_and_fidelity() { distort_appearance: true, distort_linear_gradients: true, distort_pattern_fills: true, + bounds_margin: [0.0; 4], }, ), ( @@ -6112,6 +6120,7 @@ fn an_envelope_reads_its_options_and_fidelity() { distort_appearance: false, distort_linear_gradients: false, distort_pattern_fills: true, + bounds_margin: [0.0; 4], }, ), ] { @@ -6124,6 +6133,125 @@ fn an_envelope_reads_its_options_and_fidelity() { } } +/// The red rectangle 40 20 160 80 with a red stroke 10 pt wide, its art 130 × 70 with the stroke. +fn stroked_content() -> String { + format!("{RED}0 1 1 0 1 0 0 XA\n10 w\n{}b\n", rect_path(40.0, 20.0, 160.0, 80.0)) +} + +/// A one-patch mesh over the stroked art of [`stroked_content`], and that patch as a flat grid. +fn stroked_shape() -> (String, Vec) { + let at = |(i, j): (usize, usize)| (35.0 + i as f64 * 130.0, 15.0 + j as f64 * 70.0); + let third = |p: (usize, usize), q: (usize, usize)| { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + (mesh_text(1, 1, &at, &third, None), vec![Point::new(35.0, 15.0), Point::new(165.0, 15.0), Point::new(35.0, 85.0), Point::new(165.0, 85.0)]) +} + +/// An envelope whose file saves the size of its art with the stroke maps from that box, in a +/// synthetic mesh and top object. Their drawn art is the box mapped onto the shape, and the +/// geometric box would leave the stroke 5 pt past it. +#[test] +fn an_envelope_saved_with_its_stroke_maps_from_the_stroked_box() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeMap}; + let (mesh, points) = stroked_shape(); + let keys = format!("{ENVELOPE_KEYS}70 /Real (AIDeformArtHeightKey) ,\n130 /Real (AIDeformArtWidthKey) ,\n"); + let outer = Rect::new(35.0, 15.0, 165.0, 85.0); + let flat = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }; + let drawn = drawn_box(&flat, outer, outer); + for kind in [2, 3] { + let r = read_ok(&layer(&envelope_of(&mesh, &keys, &stroked_content(), kind, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(r.warnings.is_empty(), "kind {kind}: {:?}", r.warnings); + let (.., options) = live_envelope(&a[0]); + assert_eq!(options.bounds_margin, [5.0_f32 / 60.0, 5.0 / 60.0, 5.0 / 120.0, 5.0 / 120.0]); + let src = EnvelopeMap::of(&a[0]).unwrap().src(); + assert!( + (src.x0 - 35.0).abs() < 1e-3 && (src.y0 - 15.0).abs() < 1e-3 && (src.x1 - 165.0).abs() < 1e-3 && (src.y1 - 85.0).abs() < 1e-3, + "{src:?}" + ); + } +} + +/// With Distort Appearance off the stroke is drawn after the distortion: the envelope maps from the +/// geometric box whatever size its file saves. +#[test] +fn an_envelope_without_distort_appearance_maps_from_the_geometric_box() { + use vectorcraft_doc::live::EnvelopeMap; + let keys = "65536 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n70 /Real (AIDeformArtHeightKey) ,\n130 /Real (AIDeformArtWidthKey) ,\n"; + let outer = Rect::new(35.0, 15.0, 165.0, 85.0); + let flat = vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(35.0, 15.0), Point::new(165.0, 15.0), Point::new(35.0, 85.0), Point::new(165.0, 85.0)], + handles: vec![], + }; + let drawn = drawn_box(&flat, outer, outer); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), keys, &stroked_content(), 2, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(!live_envelope(&a[0]).4.distort_appearance); + assert_eq!(live_envelope(&a[0]).4.bounds_margin, [0.0; 4]); + let src = EnvelopeMap::of(&a[0]).unwrap().src(); + assert!((src.width() - 120.0).abs() < 1e-6 && (src.height() - 60.0).abs() < 1e-6, "{src:?}"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// An envelope maps from the geometric box when its file saves that box's size, saves no size, +/// saves a size that isn't a number or saves a size far from both boxes; contents without a stroke +/// have one box, and contents with no height have no margins. +#[test] +fn an_envelope_not_saved_with_its_stroke_maps_from_the_geometric_box() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeMap}; + let (mesh, points) = stroked_shape(); + let inner = Rect::new(40.0, 20.0, 160.0, 80.0); + let flat = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }; + // The stroked art mapped from the geometric box: it reaches past the mesh by the stroke's share. + let drawn = drawn_box(&flat, inner, Rect::new(35.0, 15.0, 165.0, 85.0)); + let sizes = [ + "60 /Real (AIDeformArtHeightKey) ,\n120 /Real (AIDeformArtWidthKey) ,\n", + "", + "(tall) /UnicodeString (AIDeformArtHeightKey) ,\n(wide) /UnicodeString (AIDeformArtWidthKey) ,\n", + "70 /Real (AIDeformArtHeightKey) ,\n", + "1000 /Real (AIDeformArtHeightKey) ,\n1000 /Real (AIDeformArtWidthKey) ,\n", + "71 /Real (AIDeformArtHeightKey) ,\n131 /Real (AIDeformArtWidthKey) ,\n", + ]; + for size in sizes { + let keys = format!("{ENVELOPE_KEYS}{size}"); + let r = read_ok(&layer(&envelope_of(&mesh, &keys, &stroked_content(), 2, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{size}: {a:#?}"); + let (.., options) = live_envelope(&a[0]); + assert_eq!(options.bounds_margin, [0.0; 4], "{size}"); + let src = EnvelopeMap::of(&a[0]).unwrap().src(); + assert!((src.width() - 120.0).abs() < 1e-6 && (src.height() - 60.0).abs() < 1e-6, "{size}: {src:?}"); + } + let keys = format!("{ENVELOPE_KEYS}60 /Real (AIDeformArtHeightKey) ,\n120 /Real (AIDeformArtWidthKey) ,\n"); + let plain = drawn_content(&EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 2, &plain))); + let a = art(&r.doc); + assert_eq!(live_envelope(&a[0]).4.bounds_margin, [0.0; 4]); + // A stroke so thin that the saved geometric size also lies close to the stroked box. + let thin = format!("{RED}0 1 1 0 1 0 0 XA\n0.2 w\n{}b\n", rect_path(40.0, 20.0, 160.0, 80.0)); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &thin, 2, &plain))); + assert_eq!(live_envelope(&art(&r.doc)[0]).4.bounds_margin, [0.0; 4]); + // A stroked straight line: 120 × 0 without its stroke, and 120 × 10 as saved. + let keys = format!("{ENVELOPE_KEYS}10 /Real (AIDeformArtHeightKey) ,\n120 /Real (AIDeformArtWidthKey) ,\n"); + let line = format!("{RED}0 1 1 0 1 0 0 XA\n10 w\n40 20 m\n160 20 L\nS\n"); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &line, 2, &plain))); + for n in art(&r.doc) { + if let NodeKind::Envelope { options, .. } = &n.kind { + assert_eq!(options.bounds_margin, [0.0; 4]); + } + } +} + /// An envelope's contents keep their groups: a group of two rectangles stays that group. #[test] fn an_envelope_s_contents_keep_their_groups() { diff --git a/docs/mcp.md b/docs/mcp.md index 0b418bbc7..3283eea2a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6132,6 +6132,13 @@ them with `image-rendering: pixelated` and PDF export writes them without `/Inte of a clip group, after Expand too. New envelopes start with Distort Appearance on, as in the reference app; envelopes saved before these options existed keep their old look (appearance applied after the distortion). +An envelope opened from a `.ai` can have margins, written to native files as `boundsMargin` and reported by +`object.envelope.info` when they are not zero: `[top, bottom, left, right]`, as shares of the content's height and +width. While `distortAppearance` is on, the content maps from its geometric bounds grown by the margins, and while it +is off, the content maps from its geometric bounds. Leaving Edit Contents measures the margins again when the content's +strokes reach differently than when Edit Contents began, and always for an envelope whose file opened in Edit Contents. +New envelopes have no margins, and no command sets them. + `object.envelope.release` gives back the content and the envelope's shape (a grey gradient mesh for warp and mesh envelopes, the path for a top-object envelope); the content keeps the envelope's opacity, blend mode and opacity mask (a group around it carries them when there are several objects). `object.envelope.expand`, and `object.expand` with diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e0ea84890..5bff3542c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1231,7 +1231,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). From 9da56717a0a9dc74acf1e2b9b149daa72000fba7 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Fri, 6 Nov 2026 19:45:33 +0100 Subject: [PATCH 40/92] Fix #5714: widths run smoothly between width points The width between two width points was a straight line, so a variable-width stroke turned a corner at every width point. - Each side of a width profile now follows a monotone cubic (Fritsch-Carlson) through its points: no kink at a point, flat at a point wider or narrower than both neighbours, never past either point of a span. Points on one line stay on it and two-point tapers are unchanged; stacked points still step. - widest() counts each span's wider left and wider right, since the two sides needn't peak together. - The outliner samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece); straight spans get none. - Tests that expected straight-line widths now expect the curve. The stroke gradient coverage test uses Teardrop instead of Wave until #5964 is fixed. --- crates/doc/src/appearance/profile.rs | 161 ++++++++++++++++++-- crates/doc/src/appearance/tests.rs | 4 +- crates/effects/src/stroke/tests.rs | 10 +- crates/effects/src/stroke/tests_gradient.rs | 9 +- crates/effects/src/stroke/width.rs | 94 +++++++++++- crates/engine/src/tests/outlinestroke.rs | 5 +- crates/engine/src/tests/widthpoints.rs | 24 +-- docs/target-app-parity.md | 4 +- 8 files changed, 273 insertions(+), 38 deletions(-) diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 01b9c9fc5..0d13dc210 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -22,11 +22,15 @@ pub struct ProfilePreset { } impl WidthProfile { - /// (left, right) width factors at `t`, linear between points. + /// (left, right) width factors at `t`: a smooth curve through the points ([`Self::at_points`]). pub fn at(&self, t: f64) -> (f64, f64) { Self::at_points(&self.points, t) } - /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through + /// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point + /// without a kink, turns flat at a point wider or narrower than both neighbours, and never + /// goes past either point of the span it is in (#5714). Points on one straight line stay on + /// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it. pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { if p.is_empty() { return (1.0, 1.0); @@ -36,9 +40,21 @@ impl WidthProfile { } // (Positions go forward; found in log time, so a long profile read from a file is cheap to ask.) let i = p.partition_point(|q| q.0 < t); - if let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { - let u = (t - a.0) / (b.0 - a.0).max(1e-9); - return (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u); + if let (Some(j), Some(b)) = (i.checked_sub(1), p.get(i)) + && let Some(a) = p.get(j) + { + let h = b.0 - a.0; + let u = (t - a.0) / h.max(1e-9); + let linear = (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u); + if h <= STEP { + return linear; + } + let side = |v: fn(&(f64, f64, f64)) -> f64, straight: f64| { + let y = hermite(v(a), v(b), tangent(p, j, v) * h, tangent(p, i, v) * h, u); + // Widths read from a file may be huge: where the curve overflows, the straight line. + if y.is_finite() { y } else { straight } + }; + return (side(|q| q.1, linear.0), side(|q| q.2, linear.1)); } p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) } @@ -55,14 +71,17 @@ impl WidthProfile { // Two swells between narrow necks. ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; - /// The widest the stroke gets, as a multiple of its weight: the largest total of the left and - /// right factors at any of the points (between them the profile is straight), 1 for a profile - /// without points. + /// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each + /// side stays between the widths of the two points of each span ([`Self::at_points`]), but the + /// two sides needn't peak together, so a span counts its wider left and its wider right. pub fn widest(&self) -> f64 { - if self.points.is_empty() { + let p = &self.points; + if p.is_empty() { return 1.0; } - self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max) + let total = |l: f64, r: f64| (l.max(0.0) + r.max(0.0)) / 2.0; + let spans = p.windows(2).map(|w| total(w[0].1.max(w[1].1), w[0].2.max(w[1].2))); + p.iter().map(|&(_, l, r)| total(l, r)).chain(spans).fold(0.0, f64::max) } /// The built-in profile with this id. pub fn preset(id: &str) -> Option { @@ -227,6 +246,41 @@ impl AngleProfile { } } +/// How close two width points' positions are to make a step ([`WidthProfile::around`]). +const STEP: f64 = 1e-7; + +/// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in +/// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where +/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes +/// of the spans on either side elsewhere (never more than three times either, so no span +/// overshoots its points), and the span's own slope at an end of the profile or beside a step. +fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 { + let Some(q) = p.get(j) else { return 0.0 }; + let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP); + let after = p.get(j + 1).filter(|b| b.0 - q.0 > STEP); + let slope = |a: &(f64, f64, f64), b: &(f64, f64, f64)| (v(b) - v(a)) / (b.0 - a.0); + match (before, after) { + (Some(a), Some(b)) => { + let (d0, d1) = (slope(a, q), slope(q, b)); + if d0 * d1 <= 0.0 { + return 0.0; + } + let (h0, h1) = (q.0 - a.0, b.0 - q.0); + 3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1) + } + (Some(a), None) => slope(a, q), + (None, Some(b)) => slope(q, b), + (None, None) => 0.0, + } +} + +/// The cubic Hermite curve from `y0` to `y1` at `u` (0..1), with end slopes `m0` and `m1` per +/// whole span. +fn hermite(y0: f64, y1: f64, m0: f64, m1: f64, u: f64) -> f64 { + let (u2, u3) = (u * u, u * u * u); + (2.0 * u3 - 3.0 * u2 + 1.0) * y0 + (u3 - 2.0 * u2 + u) * m0 + (-2.0 * u3 + 3.0 * u2) * y1 + (u3 - u2) * m1 +} + /// The value of samples `p` (position, value) at `t`, linear between them; `None` without any. fn interpolated(p: &[(f64, f64)], t: f64) -> Option { let (first, last) = (p.first()?, p.last()?); @@ -358,4 +412,91 @@ mod tests { let near = WidthProfile { points: vec![(0.4999999, 2.0, 2.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25)] }; assert_eq!(near.around(0.5), ((1.0, 1.0), (0.5, 0.25))); } + + /// The slope of one side of `p` just before and just after `t`. + fn slopes(p: &WidthProfile, t: f64) -> (f64, f64) { + let e = 1e-6; + ((p.at(t).0 - p.at(t - e).0) / e, (p.at(t + e).0 - p.at(t).0) / e) + } + + /// #5714: the width flows through a width point instead of turning a corner there. + #[test] + fn widths_run_smoothly_through_width_points() { + // A swell in the middle: flat at its widest, and a quarter of the way along above the + // straight ramp from 1 to 3 (2). + let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] }; + let (before, after) = slopes(&swell, 0.5); + assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}"); + let (l, r) = swell.at(0.25); + assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}"); + // Rising on both sides of a point: the slope is the same either side of it. + let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] }; + let (before, after) = slopes(&rise, 0.3); + assert!(before > 0.0 && (before - after).abs() < 1e-3 * before, "{before} {after}"); + // Points on one line stay on it. + let straight = WidthProfile { points: vec![(0.0, 0.0, 1.0), (0.25, 0.5, 1.0), (1.0, 2.0, 1.0)] }; + for t in [0.1, 0.25, 0.6, 0.9] { + let (l, r) = straight.at(t); + assert!((l - 2.0 * t).abs() < 1e-12 && (r - 1.0).abs() < 1e-12, "{t}: {l} {r}"); + } + } + + #[test] + fn widths_never_pass_the_points_either_side() { + for preset in WidthProfile::PRESETS { + let p = WidthProfile { points: preset.points.to_vec() }; + for w in p.points.windows(2) { + let (lo, hi) = (w[0].1.min(w[1].1), w[0].1.max(w[1].1)); + for k in 0..=100 { + let t = w[0].0 + (w[1].0 - w[0].0) * k as f64 / 100.0; + let l = p.at(t).0; + assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{}: {l} at {t} outside {lo}..{hi}", preset.id); + } + } + } + // Uneven spacing and a steep jump next to a gentle rise. + let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.05, 1.0, 1.0), (0.9, 1.1, 1.1), (1.0, 5.0, 5.0)] }; + for k in 0..=1000 { + let t = k as f64 / 1000.0; + let l = p.at(t).0; + let span = p.points.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).map(|w| (w[0].1.min(w[1].1), w[0].1.max(w[1].1))); + let (lo, hi) = span.unwrap(); + assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{l} at {t}"); + } + } + + /// A step still breaks the width sharply, and the curve on either side ends at it. + #[test] + fn a_step_ends_the_curve_on_each_side() { + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (0.5, 1.0, 1.0), (1.0, 2.0, 2.0)] }; + assert_eq!(p.around(0.5), ((3.0, 3.0), (1.0, 1.0))); + // Two points between ends: each side of the step is a straight ramp. + assert!((p.at(0.25).0 - 2.0).abs() < 1e-12 && (p.at(0.75).0 - 1.5).abs() < 1e-12); + } + + #[test] + fn widest_covers_every_width_along_the_profile() { + let profiles = [ + vec![(0.0, 1.0, 0.0), (0.5, 0.2, 1.0), (1.0, 1.0, 0.0)], + vec![(0.0, 0.0, 1.0), (0.3, 1.0, 0.0), (0.6, 0.0, 1.0), (1.0, 1.0, 1.0)], + vec![(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)], + ]; + for points in profiles { + let p = WidthProfile { points }; + let most = (0..=1000).map(|k| p.at(k as f64 / 1000.0)).map(|(l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max); + assert!(p.widest() >= most - 1e-12, "{} < {most}: {:?}", p.widest(), p.points); + } + assert_eq!(WidthProfile::lens().widest(), 1.0); + assert_eq!(WidthProfile::default().widest(), 1.0); + } + + /// Widths read from a file may be absurd: the curve stays finite or falls back to the line. + #[test] + fn huge_widths_stay_finite() { + let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, f64::MAX, 1.0), (1.0, -f64::MAX, 0.0)] }; + for k in 0..=10 { + let (l, r) = p.at(k as f64 / 10.0); + assert!(!l.is_nan() && r.is_finite(), "{l} {r}"); + } + } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 089309f34..ebc491800 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -196,7 +196,9 @@ fn strokes_without_pressure_round_trip_unchanged() { fn profile_interp() { let p = WidthProfile::lens(); assert_eq!(p.at(0.5), (1.0, 1.0)); - assert_eq!(p.at(0.25), (0.5, 0.5)); + // Half way up the lens the width curves above the straight ramp (0.5), flattening into its + // widest point (#5714). + assert_eq!(p.at(0.25), (0.625, 0.625)); assert_eq!(p.at(2.0), (0.0, 0.0)); } diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index a79b8e0f7..dbe271cd8 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -231,14 +231,16 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3); assert!(near(plain.area().abs(), 1000.0, 1e-6)); let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3); - assert!(near(lens.area().abs(), 500.0, 1e-6)); - // Each dash takes its widths from where it sits along the lens (0 → 1 → 0): the dash over - // 0..25 averages a quarter of the width, the one over 50..75 three quarters. + // The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714). + assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05)); + // Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens + // rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers + // its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192. let st = stroke(10.0, |s| { s.profile = Some(WidthProfile::lens()); s.dash = Some(dashed(&[25.0, 25.0], 0.0)); }); - assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 62.5 + 187.5, 1e-6)); + assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05)); assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty()); } diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 4562c374b..b4ffc0573 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -137,8 +137,9 @@ fn across_runs_from_the_left_edge_to_the_right() { let mut lens = st.clone(); lens.profile = Some(WidthProfile::lens()); let sl = slices(&line, &lens); - // A quarter of the way along, the lens is half as wide: its top edge is 2.5 up. - assert!(param(&sl, Point::new(25.0, -2.4)).unwrap() < 0.05); + // A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is + // 3.125 up. + assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05); assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02); } @@ -182,7 +183,9 @@ fn slices_cover_everything_the_stroke_paints() { assert_covered(&zig, &st); st.arrow_align = ArrowAlign::Tip; assert_covered(&curve, &st); - st.profile = WidthProfile::preset("wave"); + // Teardrop, not Wave: with most profiles a dash across the zigzag's sharp corner + // paints outside every slice (#5964). Back to Wave once that is fixed. + st.profile = WidthProfile::preset("teardrop"); st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() }); assert_covered(&zig, &st); st.align = StrokeAlign::Outside; diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 6d0a282e8..bcaedc831 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -150,17 +150,56 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, (pts, corner) } -/// Insert samples where the profile has width points (so the piecewise-linear profile is exact -/// along straight runs; the two points of a discontinuous point share one sample). `span` maps the -/// subpath onto the profile (see [`outline_spans`]). -fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec, Vec) { +/// The most samples added between two width points. +const MAX_SPAN_SAMPLES: usize = 64; +/// The most samples [`densify`] adds between width points along one piece of a subpath. +const MAX_CURVE_SAMPLES: usize = 4096; + +/// Insert samples where the profile has width points (the two points of a discontinuous point +/// share one sample), and between them where the profile's curve ([`WidthProfile::at`]) bends, so +/// that a straight run follows it to within `tol` with a stroke of half width `half`. `span` maps +/// the subpath onto the profile (see [`outline_spans`]). +fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64), half: f64, tol: f64) -> (Vec, Vec) { let total = length(pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { return (pts.to_vec(), corner.to_vec()); } + let along = |t: f64| (t - span.0) / range * total; // Where the profile's points fall along this piece (a span past 1 wraps round a closed path). - let marks: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(|t| (t - span.0) / range * total).collect(); + let mut marks: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(along).collect(); + let mut added = 0; + for shift in [0.0, 1.0] { + for w in profile.points.windows(2) { + let (a, b) = (&w[0], &w[1]); + let (t0, t1) = (a.0 + shift, b.0 + shift); + if t1 - t0 <= 1e-7 || t1 <= span.0 || t0 >= span.1 { + continue; + } + // A span whose curve is the straight line between its points (a two-point taper, + // points on one line) needs nothing between them. A cubic off the line is off it at a + // quarter or three quarters of the way. + let off_line = |u: f64| { + let (l, r) = profile.at(a.0 + (b.0 - a.0) * u); + half * (l - (a.1 + (b.1 - a.1) * u)).abs().max((r - (a.2 + (b.2 - a.2) * u)).abs()) + }; + if off_line(0.25) <= tol * 1e-3 && off_line(0.75) <= tol * 1e-3 { + continue; + } + // A monotone cubic span bends by at most 12 times its change in width over the span + // squared, so the chords of `n` equal parts stray at most 1.5 · change / n² from it. + let change = half * (b.1 - a.1).abs().max((b.2 - a.2).abs()); + let n = (1.5 * change / tol).sqrt().ceil(); + let n = if n.is_finite() { n.clamp(1.0, MAX_SPAN_SAMPLES as f64) as usize } else { MAX_SPAN_SAMPLES }; + for k in 1..n { + if added >= MAX_CURVE_SAMPLES { + break; + } + marks.push(along(t0 + (t1 - t0) * k as f64 / n as f64)); + added += 1; + } + } + } split_at(pts, corner, closed, &marks) } @@ -225,7 +264,7 @@ struct Vertex { impl Ctx<'_> { fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) { - let (pts, corner) = densify(pts, corner, closed, self.profile, span); + let (pts, corner) = densify(pts, corner, closed, self.profile, span, self.half, self.tol); let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let seg_dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); @@ -385,9 +424,11 @@ mod tests { } #[test] - fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() { + fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() { + // Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it + // covers 7/12 of the full-width band (a diamond would cover half). let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01)); - assert!((a - 500.0).abs() < 1e-6, "{a}"); + assert!((a - 1000.0 * 7.0 / 12.0).abs() < 0.5, "{a}"); let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01); assert!((area(&o) - 500.0).abs() < 1e-6); @@ -411,6 +452,43 @@ mod tests { assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}"); } + /// #5714: between width points a straight run follows the profile's smooth curve, not a + /// straight line from one width to the next. + #[test] + fn a_straight_run_follows_the_curve_between_width_points() { + // 5 pt either side at the ends, 15 pt at the middle: a quarter of the way along the curve + // is at 2.25 × 5 = 11.25 pt (a straight ramp would be at 10 pt). + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (1.0, 1.0, 1.0)] }; + let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01); + for y in [-11.0, 11.0] { + assert!(o.contains(kurbo::Point::new(25.0, y)), "inside at y {y}"); + } + for y in [-11.5, 11.5] { + assert!(!o.contains(kurbo::Point::new(25.0, y)), "outside at y {y}"); + } + // Round the widest point the side is flat: no corner sticks out above 15 pt. + let bb = o.bounding_box(); + assert!((bb.y0 + 15.0).abs() < 1e-6 && (bb.y1 - 15.0).abs() < 1e-6, "{bb:?}"); + } + + /// A taper is a straight ramp: its outline gets no samples between the width points. + #[test] + fn a_straight_taper_gets_no_samples_between_its_points() { + let o = width_outline(&line(), 10.0, &WidthProfile::taper_end(), &capped(LineCap::Butt), 0.01); + assert_eq!(o.elements().len(), 5, "{:?}", o.elements()); + } + + /// A profile read from a file may have thousands of points and absurd widths: the samples added + /// between them stay bounded and the outline finite. + #[test] + fn samples_between_width_points_are_capped() { + let points = (0..=10_000).map(|i| (i as f64 / 10_000.0, if i % 2 == 0 { 0.0 } else { 1e12 }, 1.0)).collect(); + let o = width_outline(&line(), 10.0, &WidthProfile { points }, &capped(LineCap::Butt), 0.01); + let n = o.elements().len(); + assert!(n < 2 * (2 * 10_001 + MAX_CURVE_SAMPLES) + 64, "{n}"); + assert!(o.bounding_box().area().is_finite()); + } + #[test] fn closed_paths_make_a_ring() { let mut sq = BezPath::new(); diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index fad35ad43..926bee91b 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -72,11 +72,12 @@ fn arrowheads_are_part_of_the_outline() { } #[test] -fn a_lens_profile_outlines_to_half_the_rectangle() { +fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() { let mut s = session(); line(&mut s, json!({"weight": 10, "profile": "lens"})); let n = outline(&mut s); - assert!(near(area(&n), 0.5 * 10.0 * 100.0, 2.0), "{}", area(&n)); + // Each half of the lens swells on a cubic, flat at the middle (#5714). + assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n)); } #[test] diff --git a/crates/engine/src/tests/widthpoints.rs b/crates/engine/src/tests/widthpoints.rs index 786f3b539..fc2b21568 100644 --- a/crates/engine/src/tests/widthpoints.rs +++ b/crates/engine/src/tests/widthpoints.rs @@ -218,16 +218,22 @@ fn new_width_point_without_adjoining_preserves_neighbours() { fn new_width_point_adjust_adjoining_uses_sampled_widths() { for indexed in [false, true] { let (mut s, id) = line_session(); - s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 9, 9], [0.25, 1, 0], [0.75, 3, 0], [1, 7, 7]]})).unwrap(); - // At t=0.5 the old sides are 10pt and 0pt. Indexed editing of that sampled - // point is the existing convention: scale the nonzero side, add from zero. - let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 0}))) } else { None }; + let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)]; + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap(); + // At t=0.5 the old left side is where the profile's curve is there (#5714) and the right + // side is 0pt. Indexed editing of that sampled point is the existing convention: scale the + // nonzero side, add from zero. + let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0; + let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None }; set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true})); - assert_eq!( - points(&s, id), - vec![(0.0, 9.0, 9.0), (0.25, 2.0, 1.0), (0.5, 4.0, 1.0), (0.75, 6.0, 1.0), (1.0, 7.0, 7.0)], - "only nearest neighbours change, using the sampled sides; indexed={indexed}" - ); + let scale = 4.0 / sampled; + let got = points(&s, id); + let want = [(0.0, 9.0, 9.0), (0.25, scale, 1.0), (0.5, 4.0, 1.0), (0.75, 3.0 * scale, 1.0), (1.0, 7.0, 7.0)]; + assert_eq!(got.len(), want.len(), "{got:?}; indexed={indexed}"); + for (g, w) in got.iter().zip(want) { + let close = (g.0 - w.0).abs() < 1e-9 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9; + assert!(close, "only nearest neighbours change, using the sampled sides; indexed={indexed}: {got:?} vs {want:?}"); + } } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b820254c6..757fb0bb3 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-06 · **Change:** minor (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; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Width profiles: smooth widths between width points, #5714; 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) ## Feature submission evidence @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. | | 2026-11-05 | minor | A nearby hit on a plain long stroke or outline avoids assembling its whole path. A hit near the middle of a 200,000-anchor path took about 11–13 ms before and 1.0–1.4 ms after in paired runs on a busy Windows machine; a miss inside the path's box fell from 18.7 ms to 1.6 ms, while a distant miss was unchanged. Other stroke styles and fill hits keep their existing checks. Scores and estimates unchanged; idle performance budgets remain unverified. | | 2026-11-05 | minor | Translating a type object without changing its text or formatting reuses glyph geometry and expanded character-stroke outlines between frames. The text appears at its new position with unchanged pixels. Scaling the type, editing its content or formatting, or changing installed fonts rebuilds the geometry; scores and estimates unchanged. | | 2026-11-05 | minor | Moving a large selection reuses the object locations found during command validation. A 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs; object positions, selection, and undo behavior stay the same. Scores and estimates unchanged. | @@ -580,6 +581,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point finite-arithmetic follow-up (G1/M3):** Adjoining adjustments avoid intermediate overflow; non-finite normalized or resulting factors are rejected before committing. Engine regressions in `tests/widthpoints.rs` cover finite extremes, small nonzero results, atomic rejection, Undo/Redo and native save/reopen. Historical focused validation on the `a3357121` integration observed 2 expected RED failures with 11 passing controls, then all 13 tests passing with the repair; none were ignored. All three added regressions passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. Scores and hours unchanged. - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). - **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key 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 for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode. +- **Smooth widths between width points (#5714):** a variable-width stroke changes width along a smooth curve from one width point to the next, where it used to run in a straight line and turn a corner at each point. The public description is that the path "gradually gets wider or narrower from one point to the next" in "a flowing, continuous curve", and that stacked points make a sharp break ([Peachpit, Expressive Strokes](https://www.peachpit.com/articles/article.aspx?p=2979069)); the exact curve is not documented, so each side follows a monotone cubic (Fritsch–Carlson): no kink at a point, flat where a point is wider or narrower than both neighbours, never past the points either side, points on one line kept on it. The outline samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece). Presets with a middle point (Lens, Pinch, Teardrop, Wave) look softer; two-point tapers are unchanged. Tests: `appearance::profile::tests::{widths_run_smoothly_through_width_points, widths_never_pass_the_points_either_side, a_step_ends_the_curve_on_each_side, widest_covers_every_width_along_the_profile}`, `stroke::width::tests::{a_straight_run_follows_the_curve_between_width_points, samples_between_width_points_are_capped}`. - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). From 0c570dee87c70497a804c7b75733ea0c3050a6db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 09:02:45 -0800 Subject: [PATCH 41/92] .ai import: paths with an Art brush open with the brush on their stroke A path with an Art brush comes in as the path, with the brush on its stroke at its weight, instead of as its drawn look, when the brush stretches art of solid-filled paths along the path, unflipped, at 100% in Tints. The art runs on the side of the path the file draws it. The drawn look stays for other colorizations and widths; Proportional, between-guides and flipped brushes; type; a path with settings of its own, a variable width, effects, a swatch stroke or an entry that isn't read; and art with strokes, gradients, patterns, masks or effects, or no length. A brushed stroke, Calligraphic ones too, takes the brush the Brushes panel keeps under its name, and an image with a brush keeps its drawn look. In every document, Tints and Tints and Shades take art in black ink alone by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model. Place and Expand bring the brushes the art uses, renaming on conflict. Part of #637. --- crates/brush/src/colorize.rs | 21 +- crates/brush/src/tests.rs | 49 ++++ crates/engine/src/cmd/place.rs | 2 +- crates/engine/src/cmd/place/adopt.rs | 84 +++++- crates/engine/src/tests/place.rs | 92 ++++++ crates/engine/tests/import_fuzz.rs | 39 +++ crates/eps/src/import/ai/brush.rs | 161 ++++++++--- crates/eps/src/import/ai/read.rs | 14 + crates/eps/src/import/ai/read/directives.rs | 3 +- crates/eps/src/import/ai/read/ops.rs | 79 +++++- crates/eps/src/import/ai/read/types.rs | 2 +- crates/eps/src/import/ai/style.rs | 101 ++++++- crates/eps/src/import/ai/tests.rs | 296 +++++++++++++++++++- docs/file-format-parity.md | 2 +- docs/gaps.md | 8 +- docs/target-app-parity.md | 3 +- 16 files changed, 887 insertions(+), 69 deletions(-) diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index a62dce9ec..2cf9f43c9 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -13,15 +13,26 @@ fn luminance(c: &Color) -> f32 { } fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color { - let lum = luminance(c); + // Black ink alone is its own tint: its amount is the tint, not how light it displays (100% K + // displays lighter than black). + let light = match *c { + Color::Cmyk { c, m, y, k } if c == 0.0 && m == 0.0 && y == 0.0 => 1.0 - k, + _ => luminance(c), + }; + // A process or gray stroke color's tints and shades stay in its model. + let (white, black) = match key { + Color::Cmyk { .. } => (Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)), + Color::Gray { .. } => (Color::gray(0.0), Color::gray(1.0)), + _ => (Color::WHITE, Color::BLACK), + }; match mode { Colorization::None => *c, - Colorization::Tints => key.lerp(&Color::WHITE, lum), + Colorization::Tints => key.lerp_keep_model(&white, light), Colorization::TintsAndShades => { - if lum < 0.5 { - Color::BLACK.lerp(key, lum * 2.0) + if light < 0.5 { + key.lerp_keep_model(&black, 1.0 - light * 2.0) } else { - key.lerp(&Color::WHITE, (lum - 0.5) * 2.0) + key.lerp_keep_model(&white, (light - 0.5) * 2.0) } } Colorization::HueShift { key: art_key } => { diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 22aad6603..8bcc8ba2b 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -701,6 +701,55 @@ fn tints_colorization_uses_stroke_colour() { assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]); } +/// Black ink alone is a tint by its amount: a process black stroke leaves it as it is, and another +/// process stroke color takes that share of its inks. Tints of a process color stay process. +#[test] +fn tints_of_black_ink_are_its_amount_of_the_stroke_color() { + let tinted = |art: Color, stroke: Color| { + let mut n = rect_art(1.0, 1.0); + n.appearance.set_fill(Paint::solid(art)); + colorize(&mut n, Colorization::Tints, &Paint::solid(stroke)); + n.appearance.fill_paint().color().unwrap() + }; + let ink = Color::cmyk(0.0, 0.0, 0.0, 0.6); + let close = |a: Color, b: Color| { + let (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) = (a, b) else { panic!("{a:?} {b:?}") }; + (c - c2).abs() < 1e-5 && (m - m2).abs() < 1e-5 && (y - y2).abs() < 1e-5 && (k - k2).abs() < 1e-5 + }; + assert!(close(tinted(ink, Color::cmyk(0.0, 0.0, 0.0, 1.0)), ink)); + assert!(close(tinted(ink, Color::cmyk(0.0, 1.0, 1.0, 0.0)), Color::cmyk(0.0, 0.6, 0.6, 0.0))); + let [r, g, b] = tinted(ink, Color::rgb(1.0, 0.0, 0.0)).to_rgb(); + assert!((r - 1.0).abs() < 1e-5 && (g - 0.4).abs() < 1e-5 && (b - 0.4).abs() < 1e-5, "{r} {g} {b}"); + let Color::Gray { k } = tinted(ink, Color::gray(0.5)) else { panic!("tints of a gray stroke color stay gray") }; + assert!((k - 0.3).abs() < 1e-5, "{k}"); +} + +/// Under Tints and Shades black ink stays black, however light 100% K displays, and a band of black +/// ink is its amount: K 0.5 is the stroke color. A process or gray stroke color stays in its model. +#[test] +fn tints_and_shades_of_black_ink_go_by_its_amount() { + let mapped = |k: f32, stroke: Color| { + let mut n = rect_art(1.0, 1.0); + n.appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 0.0, k))); + colorize(&mut n, Colorization::TintsAndShades, &Paint::solid(stroke)); + n.appearance.fill_paint().color().unwrap() + }; + let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-5); + let inks = |c: Color| { + let Color::Cmyk { c, m, y, k } = c else { panic!("a process color: {c:?}") }; + [c, m, y, k] + }; + let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0); + assert!(close(inks(mapped(1.0, orange)), [0.0, 0.0, 0.0, 1.0]), "black stays black"); + assert!(close(inks(mapped(0.5, orange)), [0.0, 0.6, 1.0, 0.0]), "the stroke color"); + assert!(close(inks(mapped(0.25, orange)), [0.0, 0.3, 0.5, 0.0]), "a tint"); + assert!(close(inks(mapped(0.75, orange)), [0.0, 0.3, 0.5, 0.5]), "a shade"); + let Color::Gray { k } = mapped(0.75, Color::gray(0.4)) else { panic!("a gray stroke color stays gray") }; + assert!((k - 0.7).abs() < 1e-5, "{k}"); + let [r, g, b] = mapped(1.0, Color::rgb(1.0, 0.0, 0.0)).to_rgb(); + assert!(r < 1e-5 && g < 1e-5 && b < 1e-5, "black stays black: {r} {g} {b}"); +} + /// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide). fn type_art(fill: Paint, stroke: Paint) -> Node { use vectorcraft_doc::{CharStyle, TextObject}; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index cc9d9def0..d78ca2782 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 3493dbd0e..fb0622632 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -1,6 +1,6 @@ -//! Resources placed art brings along: the images, symbols, pattern swatches and colour swatches it -//! uses (also through symbol and pattern art and through tint swatches) join the document. A name -//! the document already uses for something different gets a free variant (`Star 2`, +//! Resources placed art brings along: the images, symbols, pattern swatches, colour swatches and +//! brushes it uses (also through symbol, pattern and brush art and through tint swatches) join the +//! document. A name the document already uses for something different gets a free variant (`Star 2`, //! `pdf-image-1 2`…) and the art's references follow; a colour linked to a swatch the document //! defines differently keeps its colour, unlinked. Text styles are left as they are: the art carries //! its own formatting. @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use std::sync::Arc; +use vectorcraft_brush::{Brush, BrushKind}; use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; @@ -31,6 +32,7 @@ enum Ref<'a> { Image(&'a mut String), Symbol(&'a mut String), Pattern(&'a mut String), + Brush(&'a mut String), /// A colour's (or gradient's) swatch link; renaming it to `None` unlinks it. Swatch(&'a mut Option), } @@ -56,7 +58,12 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) { for it in &mut n.appearance.items { match it { AppearanceItem::Fill(fl) => paint_refs(&mut fl.paint, f), - AppearanceItem::Stroke(st) => paint_refs(&mut st.paint, f), + AppearanceItem::Stroke(st) => { + paint_refs(&mut st.paint, f); + if let Some(brush) = &mut st.brush { + f(Ref::Brush(brush)); + } + } } } match &mut n.kind { @@ -80,6 +87,7 @@ struct Used { images: Vec, symbols: Vec, patterns: Vec, + brushes: Vec, swatches: Vec, } @@ -89,6 +97,7 @@ impl Used { Ref::Image(k) => (&mut self.images, &*k), Ref::Symbol(k) => (&mut self.symbols, &*k), Ref::Pattern(k) => (&mut self.patterns, &*k), + Ref::Brush(k) => (&mut self.brushes, &*k), Ref::Swatch(Some(k)) => (&mut self.swatches, &*k), Ref::Swatch(None) => return, }; @@ -116,7 +125,9 @@ fn names( let entry = match existing(name) { None => (Some(name.clone()), true), Some(e) if e == d => (Some(name.clone()), false), - Some(_) => (Some(unique_name(name, |c| taken(c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true), + Some(_) => { + (Some(unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true) + } }; out.insert(name.clone(), entry); } @@ -128,6 +139,18 @@ fn copies(m: &Renames) -> Vec<(&str, &str)> { m.iter().filter(|(_, (_, copy))| *copy).filter_map(|(old, (new, _))| Some((old.as_str(), new.as_deref()?))).collect() } +/// The art a brush holds. +fn brush_art(b: &mut Brush) -> Vec<&mut Node> { + match &mut b.kind { + BrushKind::Scatter(s) => vec![&mut s.art], + BrushKind::Art(a) => vec![&mut a.art], + BrushKind::Pattern(p) => { + [Some(&mut p.side), p.outer_corner.as_mut(), p.inner_corner.as_mut(), p.start.as_mut(), p.end.as_mut()].into_iter().flatten().collect() + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], + } +} + /// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point /// `nodes` at the names they got. pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { @@ -135,9 +158,10 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { for n in nodes.iter_mut() { refs(n, &mut |r| used.note(r)); } - // Symbol and pattern art and tint swatches can use more resources (the lists grow while they - // are walked). - let (mut si, mut pi, mut wi) = (0, 0, 0); + let src_brushes = vectorcraft_brush::library(src); + // Symbol, pattern and brush art and tint swatches can use more resources (the lists grow while + // they are walked). + let (mut si, mut pi, mut wi, mut bi) = (0, 0, 0, 0); loop { let mut art: Vec = vec![]; if let Some(name) = used.symbols.get(si) { @@ -151,6 +175,11 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { if let Some(mut paint) = src.swatch(name).map(|w| w.paint.clone()) { paint_refs(&mut paint, &mut |r| used.note(r)); } + } else if let Some(name) = used.brushes.get(bi) { + bi += 1; + if let Some(mut b) = src_brushes.iter().find(|b| b.name == *name).cloned() { + art.extend(brush_art(&mut b).into_iter().map(|a| a.clone())); + } } else { break; } @@ -182,12 +211,38 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { Some((n.clone(), entry)) }) .collect(); + // A brush whose art uses a resource that moves to another name isn't the document's brush of + // that name, whatever it looks like. + let moved = |map: &Renames, k: &str| map.get(k).is_some_and(|(new, _)| new.as_deref() != Some(k)); + let moves_art = |b: &Brush| { + let (mut b, mut any) = (b.clone(), false); + for a in brush_art(&mut b) { + refs(a, &mut |r| { + any |= match r { + Ref::Image(k) => moved(&images, k), + Ref::Symbol(k) => moved(&symbols, k), + Ref::Pattern(k) => moved(&patterns, k), + Ref::Swatch(link) => link.as_deref().is_some_and(|k| moved(&swatches, k)), + Ref::Brush(_) => false, + } + }); + } + any + }; + let mut dst_brushes = vectorcraft_brush::library(dst); + let brushes = names( + &used.brushes, + |n| src_brushes.iter().find(|b| b.name == n).map(|b| (b, moves_art(b))), + |n| dst_brushes.iter().find(|b| b.name == n).map(|b| (b, false)), + |c| dst_brushes.iter().any(|b| b.name == c), + ); let mut rename = |r: Ref| { let (map, k) = match r { Ref::Image(k) => (&images, k), Ref::Symbol(k) => (&symbols, k), Ref::Pattern(k) => (&patterns, k), + Ref::Brush(k) => (&brushes, k), Ref::Swatch(link) => { if let Some((new, _)) = link.as_deref().and_then(|k| swatches.get(k)) { *link = new.clone(); @@ -223,6 +278,19 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { dst.patterns.push(def); } } + let new_brushes = copies(&brushes); + if !new_brushes.is_empty() { + for (old, new) in new_brushes { + if let Some(mut def) = src_brushes.iter().find(|b| b.name == old).cloned() { + def.name = new.to_string(); + for a in brush_art(&mut def) { + refs(a, &mut rename); + } + dst_brushes.push(def); + } + } + vectorcraft_brush::store(dst, &dst_brushes); + } for (old, _) in copies(&swatches) { if let Some(mut w) = src.swatch(old).cloned() { paint_refs(&mut w.paint, &mut rename); diff --git a/crates/engine/src/tests/place.rs b/crates/engine/src/tests/place.rs index 3c86e513e..39e13ee40 100644 --- a/crates/engine/src/tests/place.rs +++ b/crates/engine/src/tests/place.rs @@ -662,6 +662,98 @@ fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() { assert!(linked, "still linked to the swatch it brought"); } +#[test] +fn placed_art_brings_its_brushes_renaming_on_conflict() { + // The file: a path with its own brush, and one with a brush the defaults hold. + let mut src = session(); + src.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 20, "roundness": 30, "size": 8}})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("brush.apply", &json!({"name": "Nib"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("brush.apply", &json!({"name": "Charcoal"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let brushes = |n: &Node| { + let mut names = vec![]; + n.walk(&mut |c| names.extend(c.appearance.stroke().and_then(|st| st.brush.clone()))); + names + }; + // A document with a different "Nib". + let mut s = session(); + s.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 90, "roundness": 100, "size": 2}})).unwrap(); + let before = s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().len(); + let r = place(&mut s, "art.vectorcraft", &bytes, json!({"crop": "bounding"})); + assert_eq!(brushes(&node(&s, &r["ids"][0])), ["Nib 2", "Charcoal"]); + assert_eq!(s.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(2.0), "its own stays"); + assert_eq!(s.execute("brush.get", &json!({"name": "Nib 2"})).unwrap()["size"], json!(8.0)); + assert_eq!(s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().len(), before + 1, "the same Charcoal is shared"); + // A document without it gets it as it is. + let mut fresh = session(); + let r = place(&mut fresh, "art.vectorcraft", &bytes, json!({"crop": "bounding"})); + assert_eq!(brushes(&node(&fresh, &r["ids"][0])), ["Nib", "Charcoal"]); + assert_eq!(fresh.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(8.0)); + // The name a conflict would take is not one the art already uses. + src.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib 2", "params": {"angle": 40, "roundness": 50, "size": 5}})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 90, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("brush.apply", &json!({"name": "Nib 2"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + s.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 90, "roundness": 100, "size": 2}})).unwrap(); + let r = place(&mut s, "art.vectorcraft", &bytes, json!({"crop": "bounding"})); + assert_eq!(brushes(&node(&s, &r["ids"][0])), ["Nib 3", "Charcoal", "Nib 2"]); + assert_eq!(s.execute("brush.get", &json!({"name": "Nib 3"})).unwrap()["size"], json!(8.0)); + assert_eq!(s.execute("brush.get", &json!({"name": "Nib 2"})).unwrap()["size"], json!(5.0)); +} + +/// A placed brush whose art uses an image the document holds under the same key, with other pixels, +/// isn't the document's brush of that name, though the two brushes read the same. +#[test] +fn a_placed_brush_whose_art_uses_a_renamed_image_is_copied() { + let file = |pixel: u8| { + let mut png = vec![]; + image::GrayImage::from_pixel(2, 1, image::Luma([pixel])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.edit("Brush", |d, _| { + d.images.insert("k".into(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 2, + height: 1, + xf: Default::default(), + link: None, + placement: Default::default(), + pixels: None, + }; + let art = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + let brush = vectorcraft_brush::Scatter { art, ..Default::default() }; + vectorcraft_brush::store(d, &[vectorcraft_brush::Brush { name: "Speck".into(), kind: vectorcraft_brush::BrushKind::Scatter(brush) }]); + Ok(()) + }) + .unwrap(); + s.execute("brush.apply", &json!({"name": "Speck"})).unwrap(); + serialize(&mut s, "vectorcraft") + }; + let mut s = session(); + let first = place(&mut s, "one.vectorcraft", &file(0), json!({"crop": "bounding"})); + let second = place(&mut s, "two.vectorcraft", &file(255), json!({"crop": "bounding"})); + let brush_of = |r: &Value| { + let mut name = None; + node(&s, &r["ids"][0]).walk(&mut |c| name = name.take().or_else(|| c.appearance.stroke().and_then(|st| st.brush.clone()))); + name + }; + assert_eq!(brush_of(&first).as_deref(), Some("Speck")); + assert_eq!(brush_of(&second).as_deref(), Some("Speck 2")); + let doc = &s.doc().unwrap().doc; + let key = |name: &str| { + let Some(vectorcraft_brush::Brush { kind: vectorcraft_brush::BrushKind::Scatter(b), .. }) = vectorcraft_brush::find(doc, name) else { + panic!("{name}") + }; + let NodeKind::Image(im) = &b.art.kind else { panic!("{name}: {:?}", b.art.kind_label()) }; + im.key.clone() + }; + assert_eq!((key("Speck"), key("Speck 2")), ("k".to_string(), "k 2".to_string())); +} + #[test] fn info_reports_size_resolution_colour_mode_and_link() { let mut s = session(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 8c3b2add7..57749df2a 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3587,6 +3587,15 @@ proptest! { } } + /// A `.ai` whose Art brushed path is damaged or hostile: the brush's object and art, the style's + /// entries and the path, cut short or edited, read and drawn. + #[test] + fn ai_art_brushed_paths_never_panic(cut in 0usize..1_600, edits in prop::collection::vec((0usize..1_600, prop::sample::select(vec!['0', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', 'u', 'U', 'A', 'Z'])), 0..12)) { + let text = mutate_text(&art_brushed_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai Art brushed path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// Brush objects of every kind with any parameters, the brush manager's order with any names, /// and brush art sections nested or cut short: read with the layers, or as a library without /// them, the brushes kept within their ranges. @@ -3750,6 +3759,36 @@ fn the_objects_on_path_fuzz_data_opens_live() { assert!(g.extras().objects_on_path.is_some(), "{g:?}"); } +/// Editing data with an Art brush, its art, and a path with its drawn look and the brush in its art +/// style, as the format writes them. +fn art_brushed_text() -> String { + use vectorcraft_testkit::ai; + let tool = "Adobe ArtOnPath Brush Tool"; + let object = format!( + "%AI8_BeginPluginObject\n({tool})\n(R)\n(2 / A/ / / / / 0 0/ 5 0 0 0 / 1) -\n( 1 1 0 1 0 0 0 1 1 0 0 0 0 1) .\n%AI8_EndPluginObject\n" + ); + let art = "%AI8_BeginBrushPattern\n(A)\n0 A\n0 g\n0 0 m\n40 0 L\n40 8 L\nf\n%AI8_EndBrushPattern\n"; + let style = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(S) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n({tool}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + 0 /Bool (Proportional) ,\n0 /Bool (Segmented) ,\n1 /Real (Scaling) ,\n1 /Real (MinScaling) ,\n1 /Real (MaxScaling) ,\n1 /Int (Adjustment) ,\n1 /Bool (FoldAdjustment) ,\n1 /Int (Colorization) ,\n\ + /StrokeStyle : 0 R\n0 0 0 1 K\n0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n/GObjRef : (R) ({tool}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ); + let path = "0 Ae\nu\n0 g\n10 10 m\n90 10 L\n90 30 L\nf\nU\n0 0 Xd\n6 () XW\n%_10 20 m\n%_90 20 L\n%_90 80 L\n%_n\n1 (S) XW\n"; + ai::editing_data(100.0, 100.0, &format!("{object}{art}{style}{}", ai::layer("L", path))) +} + +/// The data [`ai_art_brushed_paths_never_panic`] damages, as it is, opens with its path live and the +/// brush on its stroke. +#[test] +fn the_art_brushed_fuzz_data_opens_live() { + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&art_brushed_text())].concat(); + // Trusted: the empty page it is read beside isn't compared with its art. + let (doc, warnings, _) = vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0); + let n = doc.layers.first().and_then(|l| l.children()).and_then(|c| c.first()).unwrap_or_else(|| panic!("{warnings:?}")); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("R"), "{warnings:?} {n:?}"); +} + /// The ids of the objects of an Objects on Path group in the fuzz cases. const ON_PATH_IDS: [&str; 5] = ["f-0001", "f-0002", "f-0003", "f-0004", "f-0005"]; diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 4c9bda1b1..93d23f542 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -19,12 +19,20 @@ //! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`, //! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the //! page). +//! - A path with an Art brush is a stroke in its art style too: the filter +//! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across +//! the path), the brush by name, and its settings for this path as entries: `Scaling`, +//! `MinScaling` and `MaxScaling` (the width as a factor), `Colorization`, `Proportional`, +//! `Segmented` with `StartSegmentLength` and `EndSegmentLength`, `UpsideDown`, `Counterclockwise`, +//! `ScaleYDependsOn`, `Spacing`, `Adjustment` and `FoldAdjustment` (1 is +//! [`vectorcraft_brush::ArtBrush`]'s `overlap: false`). //! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's //! section names (one for Scatter, five for Art and Pattern, empty where there is none, each //! after a space for Art and Pattern), more fields, and last 15 numbers. //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the //! rotation follows the path (14). +//! - Art: 1 Tints (2). //! - Pattern: its scale as a factor (3). //! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle //! thickness, density, paint opacity and bristle length as fractions and the size in @@ -32,14 +40,16 @@ //! - The art is a `%AI8_BeginBrushPattern` section: its name as a string, then the art (read by //! [`super::read`]). //! -//! What isn't read keeps its default: the Art brush's options, the pattern brush's tiles (which -//! slot is which tile isn't known) and every brush's colorization, which is left at None so the -//! art keeps its own colors. +//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles +//! (which slot is which tile isn't known) and the colorization of the other brushes, which is left +//! at None so the art keeps its own colors. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use kurbo::Affine; use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; -use vectorcraft_doc::Node; +use vectorcraft_color::Paint; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use super::lex::{Lexer, Tok}; use super::obj; @@ -100,10 +110,13 @@ pub(super) enum DraftKind { brush: Scatter, art: String, }, - /// The art is the section named `art`. + /// The art is the section named `art`. `plain`: the brush stretches its art along the path, + /// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the + /// file's brush does. Art { brush: ArtBrush, art: String, + plain: bool, }, /// The five art sections the brush names, in its order (which is which tile isn't known), and /// its scale (%). @@ -204,17 +217,65 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3 Calligraphic { angle, roundness, size, variation, modes: Some(modes) } } -/// Does the brush `draft` draw as `c`, the settings a path's art style gives it? A path whose +/// The settings a path's art style gives its stroke's brush. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum PathBrush { + Calligraphic(Calligraphic), + /// An Art brush whose art stretches along the path, at the brush's own width, in Tints. + Art, +} + +/// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects +/// are left out of the pieces, a gradient or pattern stays where it was and takes no color from the +/// stroke, an effect isn't bent with its path, a stroke's outline isn't stretched along it, and art +/// with no length along the path draws nothing. +pub(super) fn art_bends(art: &Node) -> bool { + let mut bends = art.geometric_bounds().is_some_and(|b| b.width() > 1e-9); + art.walk(&mut |n| { + bends &= matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) + && n.extras().mask.is_none() + && n.appearance.effects.is_empty() + && n.appearance.items.iter().all(|i| i.effects().is_empty() && !matches!(i, AppearanceItem::Stroke(s) if !s.paint.is_none())) + && vectorcraft_doc::swatches::node_paints(n).all(|p| matches!(p, Paint::Solid { .. } | Paint::None)); + }); + bends +} + +/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does. +pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> HashSet { + let names: HashSet<&String> = drafts + .iter() + .filter_map(|d| match &d.kind { + DraftKind::Art { art, .. } => Some(art), + _ => None, + }) + .collect(); + names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect() +} + +/// Does the brush `draft` draw as `settings`, the settings a path's art style gives it? A path whose /// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and -/// has no settings of its own. -pub(super) fn draws_as(draft: &BrushDraft, c: &Calligraphic) -> bool { - let DraftKind::Calligraphic(d) = &draft.kind else { return false }; +/// has no settings of its own. `bending`: [`bending_art`]. +pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet) -> bool { let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); - close(d.angle, c.angle) - && close(d.roundness, c.roundness) - && close(d.size, c.size) - && d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b)) - && d.modes() == c.modes() + match (&draft.kind, settings) { + (DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => { + close(d.angle, c.angle) + && close(d.roundness, c.roundness) + && close(d.size, c.size) + && d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b)) + && d.modes() == c.modes() + } + (DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name), + _ => false, + } +} + +/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path, +/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths' +/// drawn look. +fn art_is_plain(numbers: &[f64; 15]) -> bool { + *numbers == [1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] } /// The brush named `name` of kind `kind` from its parameters `params`. @@ -247,7 +308,8 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< }) .collect(); let n = fields.last().and_then(|f| numbers(f)).ok_or(BrushError::Values)?; - let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) }; + let n: &[f64; 15] = n.as_slice().try_into().map_err(|_| BrushError::Values)?; + let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n; let art = || tiles.first().cloned().flatten().ok_or(BrushError::NoArt); match kind { Tool::Scatter => { @@ -268,7 +330,10 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< }; DraftKind::Scatter { brush, art: art()? } } - Tool::Art => DraftKind::Art { brush: ArtBrush { colorization: Colorization::None, ..ArtBrush::default() }, art: art()? }, + Tool::Art => { + let colorization = if *p2 == 1.0 { Colorization::Tints } else { Colorization::None }; + DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain: art_is_plain(n) } + } _ => { let tiles: [Option; 5] = tiles.try_into().map_err(|_| BrushError::Values)?; DraftKind::Pattern { tiles, scale: p3 * 100.0 } @@ -314,6 +379,43 @@ pub(super) fn art_weight(art: &Node) -> usize { weight } +/// Which of `drafts` [`finish_brushes`] makes into brushes, with [`MAX_BRUSH_ART_NODES`] of art. +pub(super) fn kept(drafts: &[BrushDraft], art: &HashMap) -> Vec { + kept_brushes(drafts, art, MAX_BRUSH_ART_NODES) +} + +/// Which of `drafts` become brushes, with at most `budget` of [`art_weight`] copied into them: not +/// a pattern brush, a brush whose art isn't read, a brush whose art would go past `budget`, or a +/// brush named as one kept before it. +pub(super) fn kept_brushes(drafts: &[BrushDraft], art: &HashMap, budget: usize) -> Vec { + let mut copied = 0usize; + let mut weights: HashMap<&str, usize> = HashMap::new(); + let mut names: HashSet<&str> = HashSet::new(); + drafts + .iter() + .map(|d| { + let fits = match &d.kind { + _ if names.contains(d.name.as_str()) => false, + DraftKind::Calligraphic(_) | DraftKind::Bristle(_) => true, + DraftKind::Pattern { .. } => false, + DraftKind::Scatter { art: name, .. } | DraftKind::Art { art: name, .. } => art.get_key_value(name).is_some_and(|(key, a)| { + let weight = *weights.entry(key.as_str()).or_insert_with(|| art_weight(a)); + let total = copied.saturating_add(weight); + let fits = total <= budget; + if fits { + copied = total; + } + fits + }), + }; + if fits { + names.insert(&d.name); + } + fits + }) + .collect() +} + /// [`finish_brushes`] with at most `budget` of [`art_weight`] copied into the brushes (each brush /// that uses a section holds its own copy): a brush whose art would go past it is left out. pub(super) fn finish_brushes_within( @@ -322,27 +424,22 @@ pub(super) fn finish_brushes_within( order: Option<&[(Tool, String)]>, budget: usize, ) -> (Vec, usize) { + let kept = kept_brushes(&drafts, art, budget); + let copy = |name: &str| art.get(name).cloned(); let mut left_out = 0; - let mut copied = 0usize; - let mut weights: HashMap<&str, usize> = HashMap::new(); - let mut copy = |name: &str| -> Option { - let (key, a) = art.get_key_value(name)?; - let weight = *weights.entry(key.as_str()).or_insert_with(|| art_weight(a)); - let total = copied.saturating_add(weight); - (total <= budget).then(|| { - copied = total; - a.clone() - }) - }; let mut made: Vec> = vec![]; - for d in drafts { + for (d, kept) in drafts.into_iter().zip(kept) { let tool = d.kind.tool(); - let repeated = made.iter().flatten().any(|(_, b)| b.name == d.name); let kind = match d.kind { - _ if repeated => None, + _ if !kept => None, DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)), DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })), - DraftKind::Art { brush, art: name } => copy(&name).map(|a| BrushKind::Art(ArtBrush { art: a, ..brush })), + DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| { + // The side of the section with y up draws on the right of the path, and a brush + // draws art's y (down here) on the left of the path. + a.transform(Affine::scale_non_uniform(1.0, -1.0), false); + BrushKind::Art(ArtBrush { art: a, ..brush }) + }), DraftKind::Pattern { .. } => None, DraftKind::Bristle(b) => Some(BrushKind::Bristle(b)), }; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 4db08ea21..226224271 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -361,6 +361,17 @@ fn swatch_names(data: &[u8]) -> ShownNames { names } +/// What the brushes and brush art read so far give a brushed stroke. +#[derive(Default)] +struct BrushesKept { + /// The counts of brushes and art sections when this was worked out. + read: (usize, usize), + /// Which of [`Reader::brush_drafts`] are kept as brushes. + kept: Vec, + /// The art an Art brush draws as the file does. + bending: HashSet, +} + struct Reader<'a> { lex: Lexer<'a>, stack: Vec, @@ -562,6 +573,8 @@ struct Reader<'a> { brush_art: HashMap, /// The brush art sections read so far, repeated names included. brush_art_sections: usize, + /// What the brushes and brush art read so far give a brushed stroke. + brushes_kept: BrushesKept, /// Brush objects that weren't read. brushes_unread: usize, /// The graph being read (see [`Reader::graph_record`]). @@ -702,6 +715,7 @@ impl<'a> Reader<'a> { brush_drafts: Vec::new(), brush_art: HashMap::new(), brush_art_sections: 0, + brushes_kept: BrushesKept::default(), brushes_unread: 0, graph: None, multi_mask: None, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 154f917e8..a5445bfbf 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1107,7 +1107,7 @@ mod tests { assert!(nodes > 0); let draft = |i: usize| brush::BrushDraft { name: format!("Brush {i}"), - kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string() }, + kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false }, }; let drafts: Vec<_> = (0..5).map(draft).collect(); let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes); @@ -1117,6 +1117,7 @@ mod tests { // A second brush of the same name is left out without using the budget. let mut repeated: Vec<_> = (0..2).map(draft).collect(); repeated.insert(1, draft(0)); + assert_eq!(brush::kept_brushes(&repeated, &reader.brush_art, 2 * nodes), [true, false, true]); let (kept, left_out) = brush::finish_brushes_within(repeated, &reader.brush_art, None, 2 * nodes); assert_eq!((kept.len(), left_out), (2, 1)); } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 28e95451d..6fb67257c 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -5,8 +5,8 @@ use super::types::{ line_width, }; use super::{ - Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap, HashSet, Instance, - Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint, + Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap, + HashSet, Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint, }; impl<'a> Reader<'a> { @@ -669,6 +669,19 @@ impl<'a> Reader<'a> { self.note_state(); } + /// Brings [`Reader::brushes_kept`] up to date with the brushes and brush art read so far. + fn note_brushes_kept(&mut self) { + use super::super::brush; + let read = (self.brush_drafts.len(), self.brush_art_sections); + if self.brushes_kept.read != read { + self.brushes_kept = BrushesKept { + read, + kept: brush::kept(&self.brush_drafts, &self.brush_art), + bending: brush::bending_art(&self.brush_drafts, &self.brush_art), + }; + } + } + /// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its /// drawn look before it (all on `%_` lines inside another such object). Other markers end what /// follows an object. @@ -705,6 +718,7 @@ impl<'a> Reader<'a> { return; } let space = self.space(); + self.note_brushes_kept(); let Some(f) = self.frames.last_mut() else { return }; let n = f.children.len(); let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else { @@ -730,6 +744,8 @@ impl<'a> Reader<'a> { names: &self.global_names, v7_patterns: &self.v7_patterns, brushes: &self.brush_drafts, + kept: &self.brushes_kept.kept, + bending: &self.brushes_kept.bending, space, }, ) @@ -874,6 +890,10 @@ struct StylePaints<'a> { v7_patterns: &'a HashSet, /// The brushes the file defines, which a brushed stroke names. brushes: &'a [super::super::brush::BrushDraft], + /// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]). + kept: &'a [bool], + /// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]). + bending: &'a HashSet, space: Affine, } @@ -1038,11 +1058,21 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p } }, StyleItem::Stroke { brush, .. } => { - // A brushed stroke draws as its look only with the brush the file defines (the - // first of its name, as the Brushes panel keeps it) at the object's settings. + // A brushed stroke draws as its look only with the brush the Brushes panel keeps + // under its name, at the object's settings, on a path, a compound path or type (an + // image draws no stroke). An Art brush is read on a path or a compound path with + // no effects. if let Some((name, settings)) = brush { - let draft = paints.brushes.iter().find(|d| &d.name == name)?; - if !super::super::brush::draws_as(draft, settings) { + let (draft, _) = paints.brushes.iter().zip(paints.kept).find(|(d, kept)| **kept && &d.name == name)?; + if !super::super::brush::draws_as(draft, settings, paints.bending) { + return None; + } + let art = matches!(settings, super::super::brush::PathBrush::Art); + let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + if !on_a_path && (art || !slot) { + return None; + } + if art && !st.effects.is_empty() { return None; } } @@ -1228,6 +1258,43 @@ mod tests { Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(fill, Paint::None, 0.0))) } + /// An image draws no stroke, so an image with a brush in its art style keeps its drawn look. + #[test] + fn an_image_with_a_brush_keeps_its_drawn_look() { + use super::super::super::brush::{Tool, parse_tool_object}; + let tool = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's name in the files + let text = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(Inked) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n({tool}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ + 6 /Real (Diameter) ,\n2 /Real (DiameterVariation) ,\n2 /Int (DiameterDependsOn) ,\n\ + 40 /Real (Roundness) ,\n10 /Real (RoundnessVariation) ,\n5 /Int (RoundnessDependsOn) ,\n\ + 30 /Real (Angle) ,\n180 /Real (AngleVariation) ,\n6 /Int (AngleDependsOn) ,\n0 /Int (AngleRelativeTo) ,\n\ + /GObjRef : (N) ({tool}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ); + let styles = crate::import::ai::style::art_styles(text.as_bytes()); + let st = &styles["Inked"]; + let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()]; + let (gradients, patterns, names, v7_patterns, bending) = + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + let paints = StylePaints { + gradients: &gradients, + patterns: &patterns, + names: &names, + v7_patterns: &v7_patterns, + brushes: &brushes, + kept: &[true], + bending: &bending, + space: Affine::IDENTITY, + }; + let look = Node::group(NodeId(1), vec![square(2, Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))]); + assert!(styled(&square(3, Paint::None), &look, st, &paints).is_some(), "a path takes the style"); + let image = + ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + assert!(styled(&Node::new(NodeId(4), NodeKind::Image(Box::new(image))), &look, st, &paints).is_none()); + } + #[test] fn data_stops_at_its_end_or_a_marker_of_the_section() { let section = Some(("BeginTextDocument", "EndTextDocument")); diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 09f752db5..17f843d9a 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -316,7 +316,7 @@ pub(super) const IGNORED: &[&str] = &[ "Ar", "Xs", "Xt", - // A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path. + // Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path. "XD", "Xi", "XI", diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 3486ceb1d..9699e3ac6 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -32,12 +32,12 @@ //! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it //! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency. //! -//! A stroke with a Calligraphic brush is its own basic filter, `(Adobe Calligraphic Brush Tool)`, with -//! its stroke's style and the brush's settings for the object as entries (see -//! [`super::brush`]). +//! A stroke with a Calligraphic or Art brush is its own basic filter, `(Adobe Calligraphic Brush +//! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for +//! the object as entries (see [`super::brush`]). //! //! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills -//! with a pattern or gradient too; strokes with a Calligraphic brush too), each with its own +//! with a pattern or gradient too; strokes with a Calligraphic or Art brush too), each with its own //! transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and //! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the @@ -50,6 +50,7 @@ use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile}; use vectorcraft_effects::is_pathfinder; +use super::brush::PathBrush; use super::lex::{Lexer, Tok}; use super::obj::V; use super::paint; @@ -78,6 +79,7 @@ const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's nam const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files +const ART_BRUSH: &str = "Adobe ArtOnPath Brush Tool"; // brand-ok: the filter's and plug-in's name in the files const CHAIN: &str = "Chain Style Filter"; const STACK: &str = "Stack Style Filter"; @@ -209,8 +211,8 @@ pub(super) enum StyleItem { miter: f64, dash: Option, profile: Option, - /// A Calligraphic brush: its name, and its settings for this object. - brush: Option<(String, vectorcraft_brush::Calligraphic)>, + /// A brush: its name, and its settings for this object. + brush: Option<(String, PathBrush)>, }, } @@ -1139,6 +1141,7 @@ fn paint_item(f: &Filter) -> Result, ()> { _ => Err(()), }, CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()), + ART_BRUSH => art_stroke(f).map(Some).ok_or(()), _ => Err(()), } } @@ -1170,7 +1173,85 @@ fn calligraphic_stroke(f: &Filter) -> Option { miter: f.miter, dash: None, profile: None, - brush: Some((name.clone(), super::brush::calligraphic(values, variation, codes))), + brush: Some((name.clone(), PathBrush::Calligraphic(super::brush::calligraphic(values, variation, codes)))), + }) +} + +/// The entries an Art brush's filter may hold, with the settings [`art_stroke`] reads. Any other +/// entry keeps the drawn look, a variable width's among them. +const ART_ENTRIES: [&str; 18] = [ + "StrokeStyle", + "DisplayString", + "Brush", + "Proportional", + "Segmented", + "UpsideDown", + "Counterclockwise", + "ScaleYDependsOn", + "Spacing", + "StartSegmentLength", + "EndSegmentLength", + "Adjustment", + "FoldAdjustment", + "Scaling", + "MinScaling", + "MaxScaling", + "Colorization", + "Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files +]; + +/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names +/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches +/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1), +/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn +/// look stays. +fn art_stroke(f: &Filter) -> Option { + if !f.paints.0 + || f.gradient + || f.pattern.is_some() + || f.repeated + || !f.arrays.is_empty() + || !f.kinds.keys().all(|k| ART_ENTRIES.contains(&k.as_str())) + { + return None; + } + let (name, plugin) = f.plugin_object.as_ref()?; + if plugin != ART_BRUSH { + return None; + } + let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0))); + let plain = off("Proportional", Kind::Bool) + && off("Segmented", Kind::Bool) + && off("UpsideDown", Kind::Int) + && off("Counterclockwise", Kind::Int) + && off("ScaleYDependsOn", Kind::Int) + && off("Spacing", Kind::Real) + && off("StartSegmentLength", Kind::Real) + && off("EndSegmentLength", Kind::Real); + if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 { + return None; + } + let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0); + if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") { + return None; + } + if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 { + return None; + } + let paint = f.stroke.clone()?; + // The tinted pieces are process colors, so a swatch's ink would be lost. + if matches!(paint, Paint::Solid { swatch: Some(_), .. }) { + return None; + } + Some(StyleItem::Stroke { + paint, + width: f.width, + cap: f.cap, + join: f.join, + miter: f.miter, + dash: None, + profile: None, + brush: Some((name.clone(), PathBrush::Art)), }) } @@ -1214,7 +1295,7 @@ impl Builder<'_> { let c = &filters[k]; match c.name.as_str() { _ if c.compound => {} - FILL | STROKE | CALLIGRAPHIC => match paint_item(c) { + FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) { Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true, Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true, _ => {} @@ -1228,7 +1309,7 @@ impl Builder<'_> { let c = &filters[k]; match c.name.as_str() { _ if c.compound => self.plain = false, - FILL | STROKE | CALLIGRAPHIC => { + FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => { paints += 1; look.visible &= c.visible; match paint_item(c) { @@ -1334,7 +1415,7 @@ fn style(filters: &[Filter]) -> ArtStyle { let f = &filters[id]; match f.name.as_str() { STACK if f.compound => b.stack(id, &mut st), - FILL | STROKE | CALLIGRAPHIC | CHAIN => { + FILL | STROKE | CALLIGRAPHIC | ART_BRUSH | CHAIN => { if let Some((item, look)) = b.item(id) { st.items.push(item); st.looks.push(look); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index b36bfba13..c6e91e3c0 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7434,7 +7434,7 @@ fn a_files_brushes_come_in_with_their_values_in_the_panels_order() { assert_eq!(s.art.geometric_bounds().map(|b| (b.width(), b.height())), Some((4.0, 4.0)), "the art of its section"); let BrushKind::Art(a) = brush_named(&lib, "Ribbon") else { panic!("art") }; assert!(a.art.geometric_bounds().is_some(), "the art of its section"); - assert_eq!(a.colorization, vectorcraft_brush::Colorization::None, "the art keeps its colors"); + assert_eq!(a.colorization, vectorcraft_brush::Colorization::Tints, "its code"); let BrushKind::Bristle(b) = brush_named(&lib, "Mop") else { panic!("bristle") }; let got = [b.stiffness, b.thickness, b.density, b.opacity, b.length, b.size]; let want = [60.0, 40.0, 30.0, 80.0, 150.0, 2.0 * 72.0 / 25.4]; @@ -7668,6 +7668,300 @@ fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { } } +/// The entries of an Art brush's filter for a path that stretches its art, at the brush's own width. +const STRETCHED: [(&str, &str, &str); 12] = [ + ("Proportional", "0", "Bool"), + ("Segmented", "0", "Bool"), + ("Scaling", "1", "Real"), + ("MinScaling", "1", "Real"), + ("MaxScaling", "1", "Real"), + ("ScaleYDependsOn", "0", "Int"), + ("Spacing", "0", "Real"), + ("UpsideDown", "0", "Int"), + ("Counterclockwise", "0", "Int"), + ("Adjustment", "1", "Int"), + ("FoldAdjustment", "1", "Bool"), + ("Colorization", "1", "Int"), +]; + +/// The style `Inked`: a stroke in red of weight `weight` with the Art brush `brush`, with the entries +/// `entries` and `more` after them. +fn art_style(brush: &str, weight: f64, entries: &[(&str, &str, &str)], more: &str) -> String { + let entries: String = entries.iter().map(|(key, value, kind)| format!("{value} /{kind} ({key}) ,\n")).collect(); + format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(Inked) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n({ART_BRUSH}) 1 0 /Filter ,\n1 /Visible ,\n(Art Brush) /Title ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + {entries}{more}/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j {weight} w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ + ({brush}) /UnicodeString (DisplayString) ,\n/GObjRef : ({brush}) ({ART_BRUSH}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n0 0 0 0 k\n0 0 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ) +} + +/// A brush's art named `name`: a wide block with a narrow one on top of it (art space: y up). +fn lopsided_art(name: &str) -> String { + format!( + "%AI8_BeginBrushPattern\n({name})\n0 A\n0 g\n{}f\n{}f\n%AI8_EndBrushPattern\n", + rect_path(0.0, 0.0, 40.0, 4.0), + rect_path(0.0, 4.0, 10.0, 8.0) + ) +} + +/// A path drawn with an Art brush comes in as the path, its stroke in that brush at its weight (the +/// weight scales the art across the path), not as its drawn look. The art runs along the path as +/// the file draws it: the side of its section with y up goes to the right of the path's direction. +#[test] +fn a_path_with_an_art_brush_comes_in_brushed() { + let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let more = "15 /Int (Adobe Effect Expand Before Version) ,\n"; + let r = read_ok(&format!("{def}{}{}", art_style("Ribbon", 2.0, &STRETCHED, more), layer(&brushed_path()))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let st = a[0].appearance.stroke().unwrap(); + assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some("Ribbon"), 2.0, Some(Color::rgb(1.0, 0.0, 0.0)))); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("art") }; + assert_eq!((brush.width, brush.colorization), (100.0, vectorcraft_brush::Colorization::Tints)); + // Along a straight line, y down: the art is 8 tall at weight 1, its narrow block below the wide one. + let line = kurbo::BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(0.0, 0.0)), kurbo::PathEl::LineTo(Point::new(100.0, 0.0))]); + let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &line, st); + let b = pieces.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!((b.height() - 16.0).abs() < 1e-3 && (b.width() - 100.0).abs() < 1e-3, "weight 2 doubles the art across, and it stretches along: {b:?}"); + assert!((b.y0 + 8.0).abs() < 1e-3, "{b:?}"); + let filled = |x: f64, y: f64| { + pieces.iter().any(|n| { + let mut hit = false; + n.walk(&mut |c| hit |= c.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), Point::new(x, y)) != 0)); + hit + }) + }; + assert!(filled(50.0, -4.0), "the wide block is above the line"); + assert!(filled(10.0, 4.0) && !filled(50.0, 4.0), "the narrow block is below it"); +} + +/// A fill under the brush stays on the path. +#[test] +fn a_filled_path_with_an_art_brush_comes_in_brushed() { + let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let style = art_style("Ribbon", 1.0, &STRETCHED, "").replace("0 0 0 0 k\n0 0 Xd", "0 0 1 0 k\n0 1 Xd"); + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}"); + assert_eq!(a[0].appearance.items.len(), 2, "{:?}", a[0].appearance); + assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon")); +} + +/// A compound path with an Art brush comes in as the compound path, its stroke in that brush. +#[test] +fn a_compound_path_with_an_art_brush_comes_in_brushed() { + let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let object = format!( + "0 Ae\nu\n{RED}{}f\nU\n0 0 Xd\n6 () XW\n%_*u\n%_20 20 m\n%_100 20 L\n%_n\n%_20 60 m\n%_100 60 L\n%_n\n%_*U\n1 (Inked) XW\n", + rect_path(20.0, 18.0, 100.0, 62.0) + ); + let r = read_ok(&format!("{def}{}{}", art_style("Ribbon", 1.0, &STRETCHED, ""), layer(&object))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Compound { .. }), "{a:#?}"); + assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon")); +} + +/// An Art brush draws only art of paths, compound paths and groups in solid fills: not an image, a +/// mask, an effect, a gradient or pattern, a stroke, or art with no length along the path. +#[test] +fn an_art_brush_bends_only_paths_in_solid_fills() { + use vectorcraft_color::gradient::{Gradient, GradientPaint, GradientStop}; + use vectorcraft_doc::{Effect, ImageObject, NodeId, OpacityMask}; + let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let r = read_ok(&format!("{def}{}", layer(&brushed_path()))); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("art") }; + let plain = &brush.art; + assert!(brush::art_bends(plain), "{plain:#?}"); + let with = |change: &dyn Fn(&mut Node)| { + let mut n = plain.clone(); + change(&mut n); + n + }; + let image = ImageObject { key: "k".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let cases = [ + ("an image", Node::new(NodeId(90), NodeKind::Image(Box::new(image)))), + ("a mask", with(&|n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(plain.clone(), true))))), + ("an effect", with(&|n| n.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: Default::default(), visible: true }))), + ( + "an effect on a fill", + with(&|n| { + n.appearance.set_fill(Paint::solid(Color::BLACK)); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.effects.push(Effect { id: "stylize.dropShadow".into(), params: Default::default(), visible: true }); + } + }), + ), + ("a gradient", with(&|n| n.appearance.set_fill(Paint::Gradient(Box::new(gradient.clone()))))), + ("a stroke", with(&|n| n.appearance.set_stroke(Paint::solid(Color::BLACK)))), + ]; + let wrong: Vec<&str> = cases.iter().filter(|(_, art)| brush::art_bends(art)).map(|(why, _)| *why).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + +/// An Art brushed path keeps its drawn look when the brush wouldn't draw it the way the file does: +/// its settings aren't those of art that stretches along the path at the brush's own width and +/// color, the brush's own aren't, its art isn't read, or the brush isn't one the panel keeps. +#[test] +fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { + let good = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let with = |key: &str, value: &str| -> Vec<(String, String, String)> { + STRETCHED.iter().map(|(k, v, t)| ((*k).to_string(), if *k == key { value.to_string() } else { (*v).to_string() }, (*t).to_string())).collect() + }; + let style_with = |key: &str, value: &str| { + let owned = with(key, value); + let entries: Vec<(&str, &str, &str)> = owned.iter().map(|(k, v, t)| (k.as_str(), v.as_str(), t.as_str())).collect(); + art_style("Ribbon", 1.0, &entries, "") + }; + let plain_style = art_style("Ribbon", 1.0, &STRETCHED, ""); + let flipped = "2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 1 1 0 1 1 0 0 1 1 0 0 0 0 1"; + let wide = "2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 1 2 0 1 0 0 0 2 2 0 0 0 0 1"; + let own_colors = "2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 0 1 0 1 0 0 0 1 1 0 0 0 0 1"; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let patterned = format!( + "%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n%AI8_EndBrushPattern\n", + rect_path(0.0, 0.0, 4.0, 4.0) + ); + let malformed: Vec<(&str, &str, &str)> = STRETCHED.iter().map(|&(k, v, t)| if k == "Segmented" { (k, "1", "Real") } else { (k, v, t) }).collect(); + let cases = [ + ("a switch written as another kind", good.clone(), art_style("Ribbon", 1.0, &malformed, "")), + ("art in a pattern", format!("{dots}{patterned}{}", plugin_object(ART_BRUSH, "Ribbon", RIBBON)), plain_style.clone()), + ("tints of a swatch's ink", good.clone(), plain_style.replace("0 1 1 0 1 0 0 XA", "0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK")), + ( + "art with no length", + format!( + "%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 g\n0 0 m\n0 40 L\nf\n%AI8_EndBrushPattern\n{}", + plugin_object(ART_BRUSH, "Ribbon", RIBBON) + ), + plain_style.clone(), + ), + ( + "art with a stroke", + format!( + "%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 G\n2 w\n{}S\n%AI8_EndBrushPattern\n{}", + rect_path(0.0, 0.0, 40.0, 8.0), + plugin_object(ART_BRUSH, "Ribbon", RIBBON) + ), + plain_style.clone(), + ), + ( + "a width other than the brush's 100%", + format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)), + art_style( + "Ribbon", + 1.0, + &STRETCHED.map(|e| if matches!(e.0, "Scaling" | "MinScaling" | "MaxScaling") { (e.0, "2", e.2) } else { e }), + "", + ), + ), + ( + "a brush that keeps its art's colors", + format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), + art_style("Ribbon", 1.0, &STRETCHED.map(|e| if e.0 == "Colorization" { (e.0, "0", e.2) } else { e }), ""), + ), + ("an entry that isn't read", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "1 /Int (SomeOtherSetting) ,\n")), + ( + "an effect on the path", + good.clone(), + plain_style.replace( + ";\n /Part ,\n;\n%AI9_EndArtStyles", + ";\n /Part ,\n/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n2 /Int (blnd) ,\n0.75 /Real (opac) ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n; /Dict ;\n /Part ,\n;\n%AI9_EndArtStyles", + ), + ), + ("proportional", good.clone(), style_with("Proportional", "1")), + ("between guides", good.clone(), style_with("Segmented", "1")), + ("its own width", good.clone(), style_with("Scaling", "2")), + ("a width that varies", good.clone(), style_with("MaxScaling", "2")), + ("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")), + ("spacing", good.clone(), style_with("Spacing", "1")), + ("flipped", good.clone(), style_with("UpsideDown", "1")), + ("flipped the other way", good.clone(), style_with("Counterclockwise", "1")), + ("another adjustment", good.clone(), style_with("Adjustment", "2")), + ("no fold adjustment", good.clone(), style_with("FoldAdjustment", "0")), + ("its own colorization", good.clone(), style_with("Colorization", "0")), + ("tints and shades", good.clone(), style_with("Colorization", "2")), + ("hue shift", good.clone(), style_with("Colorization", "3")), + ("start of a guide", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "5 /Real (StartSegmentLength) ,\n")), + ("variable width", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "2 /Int (BSWidthSource) ,\n")), + ( + "width arrays", + good.clone(), + art_style( + "Ribbon", + 1.0, + &STRETCHED, + "/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n/Array :\n1 /Real ,\n2 /Real ,\n; (BSCenteredWidthsArray) ,\n", + ), + ), + ("a brush that flips", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", flipped)), plain_style.clone()), + ("another brush width", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)), plain_style.clone()), + ("art not read", plugin_object(ART_BRUSH, "Ribbon", RIBBON), plain_style.clone()), + ("no such brush", good.clone(), art_style("Other", 1.0, &STRETCHED, "")), + ]; + let mut wrong = vec![]; + for (why, def, style) in cases { + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + let lib = vectorcraft_brush::library(&r.doc); + let art_read = why == "art not read" || why == "no such brush" || lib.iter().any(|b| b.name == "Ribbon"); + if !matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }) || !r.warnings.iter().any(|w| w.contains("drawn look")) || !art_read { + wrong.push(why); + } + } + assert!(wrong.is_empty(), "these came in live, or their art wasn't read: {wrong:?}"); +} + +/// The name a stroke's brush has is the panel's, for a Calligraphic brush too: a left-out brush of +/// that name doesn't hide the one kept. +#[test] +fn a_calligraphic_brushed_stroke_takes_the_brush_the_panel_keeps_under_its_name() { + let style = brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0); + let path = layer(&brushed_path()); + let r = read_ok(&format!("{}{}{style}{path}", plugin_object(PATTERN_BRUSH, "Six D", FENCE), plugin_object(CALLIGRAPHIC, "Six D", SIX_D))); + assert!(matches!(art(&r.doc)[0].kind, NodeKind::Path { .. }), "the pattern brush is left out"); +} + +/// The name a stroke's brush has is the panel's: a left-out brush of that name doesn't hide the one +/// kept, and a kept one of another kind isn't drawn as the name's Art brush. +#[test] +fn an_art_brushed_stroke_takes_the_brush_the_panel_keeps_under_its_name() { + let style = art_style("Ribbon", 1.0, &STRETCHED, ""); + let path = layer(&brushed_path()); + let art_def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let r = read_ok(&format!("{}{art_def}{style}{path}", plugin_object(PATTERN_BRUSH, "Ribbon", FENCE))); + assert!(matches!(art(&r.doc)[0].kind, NodeKind::Path { .. }), "the pattern brush is left out"); + let r = read_ok(&format!("{}{art_def}{style}{path}", plugin_object(CALLIGRAPHIC, "Ribbon", NIB))); + assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }), "the panel's Ribbon is the Calligraphic brush"); +} + +/// Type drawn with an Art brush keeps its drawn look under the type, which takes no style. +#[test] +fn type_with_an_art_brush_keeps_its_drawn_look() { + let own: String = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n7 /StoryIndex ,\n;\n".lines().map(|l| format!("%_{l}\n")).collect(); + let object = format!( + "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{RED}{}f\nU\n0 0 Xd\n6 () XW\n{own}1 (Inked) XW\n", + rect_path(20.0, 18.0, 100.0, 62.0) + ); + let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let r = read_ok(&format!("{def}{}{}", art_style("Ribbon", 1.0, &STRETCHED, ""), layer(&object))); + let a = art(&r.doc); + assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7))), "the look and the type, {a:#?}"); + let mut brushed = 0; + r.doc.walk(|n| brushed += usize::from(n.appearance.stroke().is_some_and(|s| s.brush.is_some()))); + assert_eq!(brushed, 0, "{:?}", r.warnings); +} + use read::{GRAPH_PLAIN, GRAPH_UNENDED}; /// Editing data in the legacy format (version 7 specification, chapter 2): `%AI5_FileFormat 2.0`, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 15e3fb9c4..ed6825e0f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -145,7 +145,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format diff --git a/docs/gaps.md b/docs/gaps.md index 7ff56d5f9..7381b10b6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -193,10 +193,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall weight, where the object's brush settings are the brush's own and its angle is on the page (#372, from a user's file of 6D Art Pen strokes: all 455 of its brushed styles), and a Calligraphic brush's Tilt, Bearing and Rotation (codes 4, 5 and 6) come in as those channels instead of Random. Others keep their drawn look. + Paths with an Art brush open the same way when the brush stretches art of solid-filled paths along the path, + unflipped, at 100% in Tints, and the path has none of its own settings, variable width, effects or a swatch + stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, + art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead - of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a - path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope + of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art + brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope (undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5bff3542c..b820254c6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1111,6 +1111,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, Stylus Wheel, and the pen's tilt, bearing and rotation on Windows, native Wayland and the web. + Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). - **Pen tilt, bearing and rotation (part of #764):** Calligraphic and Scatter Brush Options offer Tilt, Bearing and Rotation beside Fixed, Random and Pressure. The Paintbrush records along each stroke how far the pen leans, which way, and how far its barrel is turned (kept with the path, saved, and joined when a stroke continues a path). A Calligraphic value on Tilt goes from value − variation with the pen lying flat to value + variation upright; on Bearing or Rotation it adds variation × the angle over 180°, so Angle on Bearing with a Variation of 180° turns the nib with the way the pen leans. A Scatter value goes from its first value to its second over the same ranges. A stroke without that input draws the brush's own value. On Linux under X11 and XWayland the app reads the tilt and an Art Pen's barrel rotation from XInput2, on macOS it reads them from AppKit's tablet events, and `path.freehand` points and `pointer_gesture` events carry them on every platform. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page defines the three options and does not say which way each one runs; that mapping is VectorCraft's own rule. Evidence: `tests::tilt_maps_to_roundness_along_the_stroke`, `tests::bearing_and_rotation_turn_the_nib`, `tests::scatter_values_follow_tilt_and_bearing` (vectorcraft-brush), `tests_brushsym::paintbrush_records_the_pens_tilt_on_the_stroke` (vectorcraft-engine), `tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` and `mac_tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` (vectorcraft). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. @@ -1222,7 +1223,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). - A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. Pattern brushes and brushes that can't be read are left out with a note; brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its 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, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). From d208f5da64b3d3dd9f778ad2857607f46160056f Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Fri, 6 Nov 2026 19:45:33 +0100 Subject: [PATCH 42/92] Contributors: Sven Lafebre's name --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index fe8e4ddb1..6d8e37573 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -213,3 +213,6 @@ sync_github_name = false [people.adikshan11] real_name = "Adithya Shankaran" sync_github_name = true + +[people.slafebre] +real_name = "Sven Lafebre" From f534b0061a527e308e1623d5041b88fc016ea9bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 10:18:33 -0800 Subject: [PATCH 43/92] Type: switching to the Eyedropper keeps a whole-story selection With a threaded story selected by Select All, switching to the Eyedropper kept only the edited frame's selection, so a pick styled one frame. The selection now carries the story's frames, a pick or Shift-click applies to the text of every frame as one undo step, and switching back to a Type tool restores the story selection. A selection inside one frame and unthreaded type behave as before. A pick and a fill or stroke change on a selected story leave locked and hidden frames and frames on a locked artboard as they are. appearance.copyFrom leaves out a frame that is its source. Typing over a selected story no longer removes the text of a frame whose thread was removed after Select All. --- crates/engine/src/cmd/appearance.rs | 18 ++-- crates/engine/src/cmd/paint.rs | 11 ++- crates/engine/src/tests/textedit.rs | 133 ++++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 5 +- crates/tools/src/text.rs | 7 +- crates/tools/src/text/tool.rs | 6 ++ docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 8 files changed, 171 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index c8c479bc0..adf5ecca8 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames that are not locked, hidden or on a locked artboard, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -1103,14 +1103,18 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if !reverse && !defaults_only { s.eyedropper_sample = Some(node); } - let range = range.filter(|(id, _, _)| targets.contains(id)); // The highlighted characters' type objects and ranges: after Select All in threaded text, all - // the text of every frame of the story. + // the text of every frame of the story that is not locked, hidden or on a locked artboard, + // except a frame the attributes come from, in place of the object selection. let story = super::textedit::editing_story(s); let ranges: Vec<(NodeId, Option<(usize, usize)>)> = match range { - Some((id, _, _)) if story.contains(&id) => story.iter().map(|f| (*f, None)).collect(), - Some((id, start, end)) => vec![(id, Some((start, end)))], - None => vec![], + Some((id, _, _)) if story.contains(&id) => { + targets.clear(); + let editable = s.doc()?.doc.editable(); + story.iter().copied().filter(|f| Some(*f) != source && editable(*f)).map(|f| (f, None)).collect() + } + Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))], + _ => vec![], }; // Character styles cannot represent object transparency. Refuse before even changing // paint defaults; applying it to the object would also alter the unselected characters. @@ -1128,7 +1132,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if !reverse { picked.to_defaults(s, &attrs.appearance); } - if targets.is_empty() || attrs.is_empty() { + if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { return Ok(json!({ "ids": [] })); } if !ranges.is_empty() { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 390f6ef49..5031ea902 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -433,10 +433,15 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) && let Some((id, a, b)) = super::textedit::editing_range(s)? && a != b { - // After Select All in threaded text, every frame's text takes it. + // After Select All in threaded text, the text of every frame that is not locked, hidden or + // on a locked artboard takes it. let story = super::textedit::editing_story(s); - let ranges: Vec<(NodeId, usize, usize)> = - if story.is_empty() { vec![(id, a, b)] } else { story.iter().map(|f| (*f, 0, usize::MAX)).collect() }; + let ranges: Vec<(NodeId, usize, usize)> = if story.is_empty() { + vec![(id, a, b)] + } else { + let editable = s.doc()?.doc.editable(); + story.iter().copied().filter(|f| editable(*f)).map(|f| (f, 0, usize::MAX)).collect() + }; let ids: Vec = ranges.iter().map(|(f, _, _)| *f).collect(); // When the type comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index dde0c4bf5..4e1faf611 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1613,6 +1613,139 @@ fn the_eyedropper_styles_the_whole_story_that_select_all_selected() { } } +/// A red rectangle outside the story of [`threaded_story_edited_in_its_second_frame`], to pick up +/// from at (525, 325), with the Type tool editing frame `b` at its start again. +fn red_source(s: &mut Session, b: NodeId) { + let source = s.execute("shape.rectangle", &json!({"x": 500, "y": 300, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [source], "color": "#ff0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0})); +} + +fn all_red(s: &Session, id: NodeId) -> bool { + obj(s, id).runs.iter().all(|r| r.style.fill.color().is_some_and(|c| c.to_hex() == "#ff0000")) +} + +fn any_red(s: &Session, id: NodeId) -> bool { + obj(s, id).runs.iter().any(|r| r.style.fill.color().is_some_and(|c| c.to_hex() == "#ff0000")) +} + +/// A click and a Shift-click of the Eyedropper on the rectangle of [`red_source`]. +fn eyedropper_picks() -> [Mods; 2] { + [Mods::default(), Mods { shift: true, ..Default::default() }] +} + +fn pick_red_source(s: &mut Session, mods: Mods) { + s.pointer(&PointerEvent::new(PointerKind::Down, 525.0, 325.0).with_mods(mods), ViewInfo::default()).unwrap(); +} + +fn highlights(s: &mut Session) -> usize { + s.overlays(ViewInfo::default()).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { .. })).count() +} + +#[test] +fn switching_to_the_eyedropper_keeps_the_whole_story_selected() { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + red_source(&mut s, b); + s.execute("select.all", &json!({})).unwrap(); + let highlighted = highlights(&mut s); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + assert_eq!(s.tool_options()["story"], json!([a.0, b.0])); + assert!(highlights(&mut s) >= highlighted, "the Eyedropper highlights the text of both frames"); + let undo = s.doc().unwrap().history.undo.len(); + pick_red_source(&mut s, mods); + assert!(all_red(&s, a) && all_red(&s, b), "every frame takes the pick"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!any_red(&s, a) && !any_red(&s, b), "one undo takes the pick back from every frame"); + s.select_tool("type", ViewInfo::default()).unwrap(); + let o = s.tool_options(); + assert_eq!((&o["editing"], &o["story"]), (&json!(a.0), &json!([a.0, b.0])), "back in the Type tool the story stays selected"); + s.tool_text("x", ViewInfo::default()).unwrap(); + assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), "x", "typing replaces the whole story"); + } +} + +#[test] +fn a_selection_inside_one_threaded_frame_stays_in_that_frame_under_the_eyedropper() { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + red_source(&mut s, b); + s.set_tool_option("select", &json!({"start": 0, "end": 4})); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + assert!(s.tool_options().get("story").is_none()); + let undo = s.doc().unwrap().history.undo.len(); + pick_red_source(&mut s, mods); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(obj(&s, b).runs[0].text.len(), 4, "only the selected characters take the pick"); + assert!(any_red(&s, b) && !all_red(&s, b) && !any_red(&s, a)); + s.select_tool("type", ViewInfo::default()).unwrap(); + let o = s.tool_options(); + assert_eq!((o["editing"].as_u64(), o["start"].as_u64(), o["end"].as_u64()), (Some(b.0), Some(0), Some(4))); + } +} + +#[test] +fn an_eyedropper_pick_on_the_whole_story_leaves_locked_and_hidden_frames() { + for command in ["object.lock", "object.hide"] { + for held_first in [false, true] { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + let (held, other) = if held_first { (a, b) } else { (b, a) }; + s.execute(command, &json!({"ids": [held.0]})).unwrap(); + red_source(&mut s, other); + assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0])); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + pick_red_source(&mut s, mods); + assert!(all_red(&s, other), "{command}: the editable frame takes the pick"); + assert!(!any_red(&s, held), "{command}: the held frame stays as it is"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}: one undo step"); + } + } + } +} + +#[test] +fn an_eyedropper_pick_from_a_frame_of_the_selected_story_styles_the_other_frames() { + for source_first in [false, true] { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + let (source, other) = if source_first { (a, b) } else { (b, a) }; + s.execute("text.setStyle", &json!({"ids": [source.0], "fill": "#ff0000"})).unwrap(); + assert!(all_red(&s, source) && !any_red(&s, other)); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0})); + s.execute("select.all", &json!({})).unwrap(); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap(); + assert_eq!(r, json!({"ids": [other.0]})); + assert!(all_red(&s, other) && all_red(&s, source)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + } +} + +#[test] +fn typing_after_a_thread_is_removed_leaves_the_frame_that_left_the_story() { + for through_eyedropper in [false, true] { + let (mut s, _, b, _) = threaded_story_edited_in_its_second_frame(); + s.execute("select.all", &json!({})).unwrap(); + if through_eyedropper { + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + } + s.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!(s.execute("text.thread.info", &json!({})).unwrap(), json!([])); + if through_eyedropper { + s.select_tool("type", ViewInfo::default()).unwrap(); + } + let left = obj(&s, b).plain_text(); + assert!(!left.is_empty()); + s.tool_text("x", ViewInfo::default()).unwrap(); + assert_eq!(obj(&s, b).plain_text(), left, "through the Eyedropper: {through_eyedropper}"); + } +} + #[test] fn typing_over_the_whole_story_replaces_every_frames_text_in_one_step() { let (mut s, a, b, story) = threaded_story_edited_in_its_second_frame(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 66d84a925..2d85d620f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -131,8 +131,9 @@ impl Session { if self.tool.id() == id { return Ok(()); } - // Sampling type keeps its highlighted characters as the target. Capture before the - // Type tool deactivates (which commits typing and clears its editing state). + // Sampling type keeps its highlighted characters as the target, and a whole story that + // Select All selected. Capture before the Type tool deactivates (which commits typing and + // clears its editing state). let is_type = |id: &str| matches!(id, "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath"); let text_selection = ((id == "eyedropper" && is_type(self.tool.id())) || (self.tool.id() == "eyedropper" && is_type(id))) .then(|| self.tool.options()) diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 9dd96a1bf..6066baab8 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -441,8 +441,8 @@ impl TypeTool { } /// The other frames whose whole text is selected along with the edited frame's after Select - /// All in threaded text ([`Self::story`]): none once the selection is not all of the edited - /// frame's text. + /// All in threaded text ([`Self::story`]) that are still threaded with it: none once the + /// selection is not all of the edited frame's text. fn story_others(&self, cx: &ToolContext) -> Vec { let Some(id) = self.editing.filter(|id| self.story.contains(id)) else { return vec![] }; let Some(t) = Self::text(cx, id) else { return vec![] }; @@ -450,7 +450,8 @@ impl TypeTool { if a != 0 || b < edit::runs_len(&t.runs) { return vec![]; } - self.story.iter().copied().filter(|f| *f != id && Self::text(cx, *f).is_some()).collect() + let Some(thread) = cx.doc.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] }; + self.story.iter().copied().filter(|f| *f != id && thread.contains(f) && Self::text(cx, *f).is_some()).collect() } /// Move the caret (Shift keeps the anchor). Moving ends the typing session. diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 5634cf5a1..180a5e811 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -546,6 +546,12 @@ impl Tool for TypeTool { { self.start_editing(NodeId(id), b); self.anchor = a; + // The frames of a threaded story whose whole text Select All selected. + let story: Vec = + v.get("story").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).map(NodeId).collect(); + if story.len() >= 2 && story.contains(&NodeId(id)) { + self.story = story; + } } } "prevTool" | "previousTool" => { diff --git a/docs/mcp.md b/docs/mcp.md index 3283eea2a..96430c5ae 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5015,7 +5015,11 @@ and Cut take. Typing, Delete and Cut replace the whole story as one undo step (` frames whose text goes), and a fill, stroke, `type.step` or Character or Paragraph panel change applies to the text of every frame (the Character panel sends `text.setRangeStyle {id, story: true}`, which styles every frame's text in one undo step). Remove Line Breaks, Remove Paragraph Breaks and Convert to Text also act on the text of every frame, as -one undo step. A caret move or a click ends the story selection. +one undo step. A caret move or a click ends the story selection. Switching to the Eyedropper keeps the story selected, +and switching back to a Type tool restores it. `appearance.copyFrom` and `paint.sampleColor` then apply to the text of +every frame that is not locked, hidden or on a locked artboard as one undo step, and `appearance.copyFrom` leaves out a +frame that is its `source`. A fill or stroke change also leaves locked and hidden frames and frames on a locked artboard +as they are. Once a thread is removed, typing replaces the text of the edited frame only. ```json {"name":"press_key","arguments":{"key":"A","mods":{"cmd":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 757fb0bb3..81f446dca 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -735,6 +735,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - 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`). + - 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 that is not locked, hidden or on a locked artboard as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). From 1b62f86b8c3f3737fea1dd96aa09e2be3da7cc3d Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Fri, 6 Nov 2026 19:57:32 +0100 Subject: [PATCH 44/92] Fix #5714: doc comments on the width profile tests --- crates/doc/src/appearance/profile.rs | 3 +++ crates/doc/src/appearance/tests.rs | 1 + crates/effects/src/stroke/tests.rs | 1 + crates/effects/src/stroke/tests_gradient.rs | 2 ++ crates/effects/src/stroke/width.rs | 1 + crates/engine/src/tests/outlinestroke.rs | 1 + crates/engine/src/tests/widthpoints.rs | 1 + 7 files changed, 10 insertions(+) diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 0d13dc210..088796173 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -395,6 +395,7 @@ pub struct SavedProfile { mod tests { use super::*; + /// Widths and steps are found by search, at the ends, between points and at a step. #[test] fn width_profile_lookups_by_search() { let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25), (1.0, 0.0, 0.0)] }; @@ -441,6 +442,7 @@ mod tests { } } + /// Neither side of a profile goes wider or narrower than the two points of the span it is in. #[test] fn widths_never_pass_the_points_either_side() { for preset in WidthProfile::PRESETS { @@ -474,6 +476,7 @@ mod tests { assert!((p.at(0.25).0 - 2.0).abs() < 1e-12 && (p.at(0.75).0 - 1.5).abs() < 1e-12); } + /// `widest()` is at least the stroke's width anywhere along the profile, even where the sides peak apart. #[test] fn widest_covers_every_width_along_the_profile() { let profiles = [ diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index ebc491800..c297b3475 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -192,6 +192,7 @@ fn strokes_without_pressure_round_trip_unchanged() { assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None)); } +/// The Lens profile's widths at its middle, a quarter of the way along and past its end. #[test] fn profile_interp() { let p = WidthProfile::lens(); diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index dbe271cd8..46969ff63 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -225,6 +225,7 @@ fn expand_stroke_region_of_a_round_line_stays_compact() { assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors"); } +/// A line's outline takes its width profile, its dashes (each reading the profile where it sits) or neither. #[test] fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let l = line(0.0, 100.0); diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index b4ffc0573..489182406 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -117,6 +117,7 @@ fn along_keeps_a_corners_colour_round_its_outside() { assert!((param(&sl, Point::new(95.0, 8.0)).unwrap() - 0.54).abs() < 1e-6); } +/// An Across stroke gradient runs from the stroke's left edge to its right edge, also where a width profile moves them. #[test] fn across_runs_from_the_left_edge_to_the_right() { let st = stroke(10.0, StrokeGradientMode::Across); @@ -163,6 +164,7 @@ fn an_aligned_stroke_spans_the_gradient_across_the_side_it_shows_on() { } } +/// The gradient slices cover every point the stroke paints, with each join, caps, arrowheads, a profile and dashes. #[test] fn slices_cover_everything_the_stroke_paints() { let zig = poly(&[(0.0, 0.0), (60.0, 40.0), (0.0, 80.0), (70.0, 90.0), (75.0, 20.0)], false); diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index bcaedc831..4a9dc2678 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -423,6 +423,7 @@ mod tests { assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6); } + /// The Lens outline swells on its curve, and a profile with one side at zero paints only the other side. #[test] fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() { // Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 926bee91b..955e9798e 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -71,6 +71,7 @@ fn arrowheads_are_part_of_the_outline() { assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape"); } +/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle. #[test] fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() { let mut s = session(); diff --git a/crates/engine/src/tests/widthpoints.rs b/crates/engine/src/tests/widthpoints.rs index fc2b21568..4c837c300 100644 --- a/crates/engine/src/tests/widthpoints.rs +++ b/crates/engine/src/tests/widthpoints.rs @@ -214,6 +214,7 @@ fn new_width_point_without_adjoining_preserves_neighbours() { new_point_adjoining(false); } +/// A new width point with Adjust Adjoining scales only its nearest neighbours, by the widths sampled where it goes. #[test] fn new_width_point_adjust_adjoining_uses_sampled_widths() { for indexed in [false, true] { From c969b7857df0d7d9539b8e6039e457781277e6f1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 11:05:27 -0800 Subject: [PATCH 45/92] Type: an Eyedropper pick on a selected story styles every frame appearance.copyFrom and a fill or stroke change on a story selected by Select All no longer skip locked frames, hidden frames and frames on a locked artboard. They apply to every frame of the story, as the Character panel's story edits do. appearance.copyFrom still leaves out a frame that is its source. --- crates/engine/src/cmd/appearance.rs | 9 ++++----- crates/engine/src/cmd/paint.rs | 11 +++-------- crates/engine/src/tests/textedit.rs | 6 +++--- docs/mcp.md | 5 ++--- docs/target-app-parity.md | 2 +- 5 files changed, 13 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index adf5ecca8..9cfedcb7a 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames that are not locked, hidden or on a locked artboard, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -1104,14 +1104,13 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { s.eyedropper_sample = Some(node); } // The highlighted characters' type objects and ranges: after Select All in threaded text, all - // the text of every frame of the story that is not locked, hidden or on a locked artboard, - // except a frame the attributes come from, in place of the object selection. + // the text of every frame of the story, except a frame the attributes come from, in place of + // the object selection. let story = super::textedit::editing_story(s); let ranges: Vec<(NodeId, Option<(usize, usize)>)> = match range { Some((id, _, _)) if story.contains(&id) => { targets.clear(); - let editable = s.doc()?.doc.editable(); - story.iter().copied().filter(|f| Some(*f) != source && editable(*f)).map(|f| (f, None)).collect() + story.iter().copied().filter(|f| Some(*f) != source).map(|f| (f, None)).collect() } Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))], _ => vec![], diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 5031ea902..390f6ef49 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -433,15 +433,10 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) && let Some((id, a, b)) = super::textedit::editing_range(s)? && a != b { - // After Select All in threaded text, the text of every frame that is not locked, hidden or - // on a locked artboard takes it. + // After Select All in threaded text, every frame's text takes it. let story = super::textedit::editing_story(s); - let ranges: Vec<(NodeId, usize, usize)> = if story.is_empty() { - vec![(id, a, b)] - } else { - let editable = s.doc()?.doc.editable(); - story.iter().copied().filter(|f| editable(*f)).map(|f| (f, 0, usize::MAX)).collect() - }; + let ranges: Vec<(NodeId, usize, usize)> = + if story.is_empty() { vec![(id, a, b)] } else { story.iter().map(|f| (*f, 0, usize::MAX)).collect() }; let ids: Vec = ranges.iter().map(|(f, _, _)| *f).collect(); // When the type comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 4e1faf611..b58f62798 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1687,7 +1687,7 @@ fn a_selection_inside_one_threaded_frame_stays_in_that_frame_under_the_eyedroppe } #[test] -fn an_eyedropper_pick_on_the_whole_story_leaves_locked_and_hidden_frames() { +fn an_eyedropper_pick_on_the_whole_story_styles_locked_and_hidden_frames() { for command in ["object.lock", "object.hide"] { for held_first in [false, true] { for mods in eyedropper_picks() { @@ -1699,8 +1699,8 @@ fn an_eyedropper_pick_on_the_whole_story_leaves_locked_and_hidden_frames() { s.select_tool("eyedropper", ViewInfo::default()).unwrap(); let undo = s.doc().unwrap().history.undo.len(); pick_red_source(&mut s, mods); - assert!(all_red(&s, other), "{command}: the editable frame takes the pick"); - assert!(!any_red(&s, held), "{command}: the held frame stays as it is"); + assert!(all_red(&s, other), "{command}: the other frame takes the pick"); + assert!(all_red(&s, held), "{command}: the held frame takes the pick"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}: one undo step"); } } diff --git a/docs/mcp.md b/docs/mcp.md index 96430c5ae..fe8eff6f4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5017,9 +5017,8 @@ every frame (the Character panel sends `text.setRangeStyle {id, story: true}`, w undo step). Remove Line Breaks, Remove Paragraph Breaks and Convert to Text also act on the text of every frame, as one undo step. A caret move or a click ends the story selection. Switching to the Eyedropper keeps the story selected, and switching back to a Type tool restores it. `appearance.copyFrom` and `paint.sampleColor` then apply to the text of -every frame that is not locked, hidden or on a locked artboard as one undo step, and `appearance.copyFrom` leaves out a -frame that is its `source`. A fill or stroke change also leaves locked and hidden frames and frames on a locked artboard -as they are. Once a thread is removed, typing replaces the text of the edited frame only. +every frame as one undo step, and `appearance.copyFrom` leaves out a frame that is its `source`. Once a thread is +removed, typing replaces the text of the edited frame only. ```json {"name":"press_key","arguments":{"key":"A","mods":{"cmd":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 81f446dca..f128c1e9d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -735,7 +735,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - 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`). - - 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 that is not locked, hidden or on a locked artboard 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`). + - Switching from a Type tool to the Eyedropper keeps a whole story selected by Select All: a pick applies to the text of every frame as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). From 22bb2ac8a66a33e6e64c95c08cd35886b0c6352f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 11:48:00 -0800 Subject: [PATCH 46/92] Type: a selected story's highlight and typing follow its current thread With a whole story selected, the highlight and typing read the frames saved at Select All, while a pick, a fill and the other story-wide edits read the document's thread. A frame added by text.thread.add took a pick without being highlighted, and typing left its text in place. Both now read the thread through Document::story_of. A frame threaded in during the selection is highlighted and takes the edit, and a frame that leaves the thread is not highlighted and keeps its text. --- crates/engine/src/tests/textedit.rs | 52 +++++++++++++++++++++++++++++ crates/tools/src/text.rs | 14 ++++---- docs/mcp.md | 6 ++-- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index b58f62798..157da62b4 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1726,6 +1726,58 @@ fn an_eyedropper_pick_from_a_frame_of_the_selected_story_styles_the_other_frames } } +/// Whether a character in the frame that `text.thread.add` places at [`THREADED_AT`] is highlighted. +fn third_frame_highlighted(s: &mut Session) -> bool { + let in_frame = |o: &vectorcraft_tools::Overlay| matches!(o, vectorcraft_tools::Overlay::Highlight { quad, color: [60, 120, 255, 90] } if quad.iter().all(|p| p.x > 690.0)); + s.overlays(ViewInfo::default()).iter().any(in_frame) +} + +/// Where a third frame of the story of [`threaded_story_edited_in_its_second_frame`] goes, clear +/// of its other frames. +const THREADED_AT: [f64; 2] = [700.0, 10.0]; + +#[test] +fn a_frame_threaded_into_the_selected_story_is_highlighted_and_takes_the_edit() { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + red_source(&mut s, b); + s.execute("select.all", &json!({})).unwrap(); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let c = s.execute("text.thread.add", &json!({"from": a.0, "before": true, "at": THREADED_AT})).unwrap()["id"].as_u64().unwrap(); + let c = NodeId(c); + assert!(!obj(&s, c).plain_text().is_empty(), "the story starts in the new frame"); + assert!(third_frame_highlighted(&mut s), "the new frame's text is highlighted"); + pick_red_source(&mut s, mods); + assert!(all_red(&s, a) && all_red(&s, b) && all_red(&s, c), "every frame takes the pick"); + s.select_tool("type", ViewInfo::default()).unwrap(); + assert!(third_frame_highlighted(&mut s), "back in the Type tool too"); + s.tool_text("x", ViewInfo::default()).unwrap(); + let text = [a, b, c].map(|f| obj(&s, f).plain_text()).concat(); + assert_eq!(text, "x", "typing replaces the text of every frame"); + } +} + +#[test] +fn a_frame_that_left_the_selected_story_is_not_highlighted_and_leaves_the_edit() { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + let c = s.execute("text.thread.add", &json!({"from": b.0, "at": THREADED_AT})).unwrap()["id"].as_u64().unwrap(); + let c = NodeId(c); + red_source(&mut s, b); + assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0, c.0])); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + s.execute("text.thread.break", &json!({"id": b.0, "port": "out"})).unwrap(); + s.execute("text.setText", &json!({"id": c.0, "text": "Kept"})).unwrap(); + assert!(!third_frame_highlighted(&mut s), "the frame that left has no highlight"); + pick_red_source(&mut s, mods); + assert!(all_red(&s, a) && all_red(&s, b), "the frames still threaded take the pick"); + assert!(!any_red(&s, c), "the frame that left stays as it is"); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.tool_text("x", ViewInfo::default()).unwrap(); + assert_eq!(obj(&s, c).plain_text(), "Kept", "typing leaves the frame that left"); + } +} + #[test] fn typing_after_a_thread_is_removed_leaves_the_frame_that_left_the_story() { for through_eyedropper in [false, true] { diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 6066baab8..20a31deb5 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -174,9 +174,10 @@ pub struct TypeTool { /// Caret and selection anchor (byte offsets into the plain text). caret: usize, anchor: usize, - /// After Select All in a frame of threaded text: the story's frames in thread order, the edited - /// one included. While the selection is the edited frame's whole text, the text of the other - /// frames is selected too. Empty otherwise. + /// After Select All in a frame of threaded text: the story's frames in thread order then, the + /// edited one included. While the selection is the edited frame's whole text and this holds the + /// edited frame, the text of the other frames of its current thread is selected too. Empty + /// otherwise. story: Vec, /// Horizontal position kept by Up/Down (text space). goal_x: Option, @@ -441,8 +442,8 @@ impl TypeTool { } /// The other frames whose whole text is selected along with the edited frame's after Select - /// All in threaded text ([`Self::story`]) that are still threaded with it: none once the - /// selection is not all of the edited frame's text. + /// All in threaded text ([`Self::story`]), read from the document's current thread: none once + /// the selection is not all of the edited frame's text. fn story_others(&self, cx: &ToolContext) -> Vec { let Some(id) = self.editing.filter(|id| self.story.contains(id)) else { return vec![] }; let Some(t) = Self::text(cx, id) else { return vec![] }; @@ -450,8 +451,7 @@ impl TypeTool { if a != 0 || b < edit::runs_len(&t.runs) { return vec![]; } - let Some(thread) = cx.doc.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] }; - self.story.iter().copied().filter(|f| *f != id && thread.contains(f) && Self::text(cx, *f).is_some()).collect() + cx.doc.story_of(id).into_iter().filter(|f| *f != id).collect() } /// Move the caret (Shift keeps the anchor). Moving ends the typing session. diff --git a/docs/mcp.md b/docs/mcp.md index fe8eff6f4..56d6e2d67 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5017,8 +5017,10 @@ every frame (the Character panel sends `text.setRangeStyle {id, story: true}`, w undo step). Remove Line Breaks, Remove Paragraph Breaks and Convert to Text also act on the text of every frame, as one undo step. A caret move or a click ends the story selection. Switching to the Eyedropper keeps the story selected, and switching back to a Type tool restores it. `appearance.copyFrom` and `paint.sampleColor` then apply to the text of -every frame as one undo step, and `appearance.copyFrom` leaves out a frame that is its `source`. Once a thread is -removed, typing replaces the text of the edited frame only. +every frame as one undo step, and `appearance.copyFrom` leaves out a frame that is its `source`. While the story stays +selected, its frames are the ones threaded with the edited frame at that moment: a frame that `text.thread.add` adds is +highlighted and takes the edit, and a frame that leaves the thread is not highlighted and keeps its text. Once a thread +is removed, typing replaces the text of the edited frame only. ```json {"name":"press_key","arguments":{"key":"A","mods":{"cmd":true}}} From 4a5ff880ff844e5142874a96c5dee3f6e0061b71 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 07:42:09 -0800 Subject: [PATCH 47/92] Canvas: selection outlines and anchors are reused between frames The canvas flattened every selected object's outline and tessellated its anchors and handles again in each frame. It now keeps them per object as one mesh, valid for the object's node, layer color and selected anchors. An idle frame draws the mesh again. A pan moves kept outlines and handles and places the anchor squares again. A frame with a new zoom, rotation or display scale draws plain shapes and keeps nothing, and the next frame at that view builds the marks. Entries of objects that leave the selection or the document are dropped. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/caches.rs | 3 + crates/ui-egui/src/canvas.rs | 11 +- crates/ui-egui/src/canvas/overlays.rs | 166 +--- crates/ui-egui/src/canvas/selection_marks.rs | 864 ++++++++++++++++++ .../src/canvas/selection_marks/tests.rs | 525 +++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 1443 insertions(+), 128 deletions(-) create mode 100644 crates/ui-egui/src/canvas/selection_marks.rs create mode 100644 crates/ui-egui/src/canvas/selection_marks/tests.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index faffd897b..0b508abfa 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -80,6 +80,7 @@ impl VectorcraftApp { print_tiling: None, live_paint_gaps: None, hidden_chars: Default::default(), + selection_marks: Default::default(), selection_box: None, selection_bounds: None, content_bounds: None, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 736100377..ddb649708 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -41,6 +41,9 @@ pub struct CanvasCache { /// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame /// that does not draw them drops them ([`canvas::show`]). pub hidden_chars: canvas::HiddenChars, + /// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that + /// does not draw them drops them ([`canvas::show`]). + pub selection_marks: canvas::SelectionMarks, /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). pub selection_box: Option<((u64, u64, bool), Option)>, /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8e3315e35..6210c2826 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -28,6 +28,7 @@ mod outline; mod overlays; mod panes; mod raster; +mod selection_marks; mod task_bar; mod tool_overlays; mod view; @@ -65,6 +66,7 @@ pub use panes::Panes; pub(crate) use panes::{consolidate, pane, tile, tiled_area}; use raster::{changed_pixels, pixel_region, upload}; pub(crate) use raster::{color_image, color_image_owned}; +pub use selection_marks::SelectionMarks; use task_bar::task_bar; pub(crate) use task_bar::{has_colors, task_bar_rect}; pub use tool_overlays::HiddenChars; @@ -193,6 +195,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // that does not draw the overlay drops them, for example with the option off, with the edges // hidden, on the Home screen or with no document open. let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars); + // The selection's kept marks. Likewise, a frame that does not draw them drops them. + let selection_marks = std::mem::take(&mut app.canvas.selection_marks); // A release ends the Image Trace panel's eye-button hold, also when the panel is no longer // drawn, so a later press elsewhere does not show the source image. if app.ui.trace_hold_source.is_some() && !ui.input(|i| i.pointer.any_down()) { @@ -336,7 +340,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } original_path_overlay(app, &painter, &xf); - let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, hover_still), revolve_edit, view_info); + let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, selection_marks, hover_still), revolve_edit, view_info); draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); if !revolve_edit { shaper_widget_overlay(app, &painter, &xf); @@ -894,7 +898,7 @@ fn selection_visuals( app: &mut VectorcraftApp, painter: &egui::Painter, xf: &Xf, - (hidden_chars, hover_still): (HiddenChars, bool), + (hidden_chars, mut marks, hover_still): (HiddenChars, SelectionMarks, bool), revolve_edit: bool, view_info: vectorcraft_engine::ViewInfo, ) -> Vec { @@ -910,7 +914,8 @@ fn selection_visuals( if hover_still { hover_highlight(app, painter, xf); } - selection_overlay(app, painter, xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. }))); + selection_overlay(app, painter, xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })), &mut marks); + app.canvas.selection_marks = marks; draw_inside_overlay(app, painter, xf); mirror_axis_overlay(app, painter, xf); grid_repeat_overlay(app, painter, xf); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 206b095ca..8779380ec 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,7 +1,8 @@ use super::artboards::names_an_artboard; use super::input::marquee_through; -use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors, walk_drawn}; -use super::{Color32, CornerRadius, NodeKind, Overlay, Point, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; +use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors}; +use super::selection_marks::{MarkOptions, SelectionMarks}; +use super::{Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(h) = app.hover_doc else { return }; @@ -54,9 +55,9 @@ pub(super) fn selected_anchor(c: Color32) -> Color32 { #[derive(Clone, Copy)] pub(super) struct HandleLook<'a> { /// Points bigger (or smaller) than the default size 3, per step of Size (1–7). - grow: f32, + pub(super) grow: f32, /// `solid` (the default), `hollow` or `large`. - style: &'a str, + pub(super) style: &'a str, } impl<'a> HandleLook<'a> { @@ -65,17 +66,21 @@ impl<'a> HandleLook<'a> { } /// A direction handle's end at `c`: a solid dot, a hollow one or a larger solid one. - fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) { + pub(super) fn shapes(self, c: Pos2, color: Color32) -> [Option; 2] { let r = 2.75 + self.grow / 2.0; let hollow = self.style == "hollow"; - p.circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color }); - if hollow { - p.circle_stroke(c, r, Stroke::new(1.0, color)); + let dot = Shape::circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color }); + [Some(dot), hollow.then(|| Shape::circle_stroke(c, r, Stroke::new(1.0, color)))] + } + + pub(super) fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) { + for shape in self.shapes(c, color).into_iter().flatten() { + p.add(shape); } } } -fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { +pub(super) fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { let r = egui::Rect::from_center_size(c, vec2(size, size)); if filled { p.rect_filled(r, 0.0, color); @@ -85,11 +90,23 @@ fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: } } -fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { +pub(super) fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { p.circle(c, size / 2.0, if filled { color } else { Color32::WHITE }, Stroke::new(1.0, color)); } -pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) { +/// How the current tool and preferences draw the selection's marks. +pub(super) fn mark_options(app: &VectorcraftApp, curve_points_from_tool: bool) -> MarkOptions<'_> { + let tool = app.session.tool_id(); + MarkOptions { + direct: vectorcraft_tools::catalog::edits_anchors(tool), + curvature: tool == "curvature", + curve_points_from_tool, + look: HandleLook::of(&app.session.prefs), + all_handles: app.session.prefs.show_handles_multiple_anchors, + } +} + +pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool, marks: &mut SelectionMarks) { // The Selection tool's bounding box (rotated with the objects after a rotation), also shown by // the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It // hides while a drag moves or resizes the selection, so only the art is seen going with the @@ -121,130 +138,29 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: let symbols = st.doc.symbol_boxes(); let tool = app.session.tool_id(); let direct = vectorcraft_tools::catalog::edits_anchors(tool); - let curvature = tool == "curvature"; // Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding // box's handles a point bigger or smaller per step. - let look = HandleLook::of(&app.session.prefs); - let grow = look.grow; + let options = mark_options(app, curve_points_from_tool); + let grow = options.look.grow; let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 }; - // The selected anchors' handles (drawn once they are counted: Show handles when multiple - // anchors are selected off shows them for a single one only). - let (mut handles, mut with_handles) = (vec![], 0); // Too many paths to outline: the selection's bounds stand for them. if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) { stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id)))); } // The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has // too many paths to outline. - if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) { - let outline = if too_many_anchors([n]) { - n.geometric_bounds_with(&symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()) - } else { - Some(node_outline(n, &symbols)) - }; - if let Some(bp) = outline { - stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); - } - } - for id in st.selection.objects.iter().filter(|_| !big) { - let Some(n) = st.doc.node(*id) else { continue }; - let color = c32(st.doc.layer_color(*id)); - let partial = st.selection.partial(*id); - // Path outlines (and area type / placed document / image / mesh / symbol outlines; - // point type baselines and origin are drawn below). - if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) { - stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color)); - } - // Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh - // envelope's points. - walk_drawn(n, &mut |c| { - let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid); - let grid = match &c.kind { - NodeKind::Mesh(m) => Some(m), - _ => envelope.as_ref(), - }; - if let Some(grid) = grid { - for q in &grid.points { - anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct)); - } - return; - } - let NodeKind::Path { path, .. } = &c.kind else { return }; - // The Curvature tool owns its points while drawing; existing paths use this overlay. - if curvature && curve_points_from_tool { - return; - } - let selected_segments = st.selection.segments.get(&c.id); - if let Some(segments) = selected_segments { - for &(si, seg) in segments { - let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue }; - let curve = sp.segment(seg); - let mut outline = vectorcraft_geom::BezPath::new(); - outline.move_to(curve.p0); - if sp.segment_is_line(seg) { - outline.line_to(curve.p3); - } else { - outline.curve_to(curve.p1, curve.p2, curve.p3); - } - stroke_path(p, &outline, xf, Stroke::new(2.0, color)); - } - } - for (si, ai, a) in path.anchors() { - // An object selected whole has all its anchors selected, and so do the paths inside a - // group, compound path or clip group selected whole. Direction handles show only on a - // path that is selected itself: those are the handles that `handle_anchors` in - // `vectorcraft-tools` lets the tools drag. - let (sel, handles_shown) = match partial { - Some(set) => { - let sel = set.contains(&(si, ai)); - (sel, sel) - } - None => (true, direct && c.id == *id), - }; - let sp = xf.to_screen(a.p); - if !curvature && handles_shown { - with_handles += 1; - let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true)); - handles.extend( - [(a.h_in, sides.0), (a.h_out, sides.1)] - .into_iter() - .filter(|(h, show)| *show && h.distance(a.p) > 1e-6) - .map(|(h, _)| (sp, xf.to_screen(h), color)), - ); - } - let anchor_selected = sel && selected_segments.is_none(); - let ink = if anchor_selected && partial.is_some() { selected_anchor(color) } else { color }; - if curvature { - curve_point(p, sp, ink, anchor_selected && partial.is_some(), grow + 6.0); - } else { - anchor_square(p, sp, ink, anchor_selected, anchor(partial.is_some() || direct)); - } - } - }); - // Centre point (Attributes panel → Show Center). - if n.shows_center() - && let Some(b) = n.geometric_bounds_with(&symbols) - { - anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false)); - } - // Point type: each line's baseline, where a click selects it (`hit`), whatever its - // alignment and rotation, and the anchor (area type shows its frame instead, which may - // not be a rectangle, and type on a path its path). - if let NodeKind::Text(tx) = &n.kind - && matches!(tx.kind, vectorcraft_doc::TextKind::Point) - { - for (a, b) in tx.baselines() { - p.line_segment([xf.to_screen(tx.xf * a), xf.to_screen(tx.xf * b)], Stroke::new(1.0, color)); - } - p.circle_filled(xf.to_screen(tx.xf * Point::ZERO), 2.5, color); - } - } - if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 { - for (sp, hp, color) in handles { - p.line_segment([sp, hp], Stroke::new(1.0, color)); - look.draw(p, hp, color); - } + let key = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))); + let (key_bounds, key_outline) = match key { + Some((k, n)) if too_many_anchors([n]) => (Some((k, n)), None), + Some((k, _)) => (None, Some(k)), + None => (None, None), + }; + if let Some((k, n)) = key_bounds + && let Some(b) = n.geometric_bounds_with(&symbols) + { + stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); } + marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects }); // The spine of each selected blend (or of the blend a selected key object belongs to). let mut spines = vec![]; for id in &st.selection.objects { diff --git a/crates/ui-egui/src/canvas/selection_marks.rs b/crates/ui-egui/src/canvas/selection_marks.rs new file mode 100644 index 000000000..d0d01b312 --- /dev/null +++ b/crates/ui-egui/src/canvas/selection_marks.rs @@ -0,0 +1,864 @@ +//! The selected objects' outlines, anchors and handles, kept between frames as triangles. +//! +//! Each selected object has an [`ObjectMarks`], valid for one node (by [`Arc`] identity), layer +//! color and set of selected anchors. The marks of all objects are joined into one mesh, in the +//! order they are drawn. A frame with nothing changed draws that mesh again. A pan moves the +//! triangles of outlines and handles, and places the anchor squares again, because a square's edges +//! are rounded to whole pixels where it lands. A frame with a new zoom, rotation or display scale +//! draws plain shapes and keeps nothing, and the next frame with the same view builds the marks. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::{Arc, Weak}; + +use egui::emath::{GuiRounding as _, TSTransform}; +use egui::epaint::{ClippedShape, Primitive, RectShape, TessellationOptions, Tessellator, Vertex}; +use egui::{Mesh, Vec2}; +use vectorcraft_doc::{AnchorRef, NodeId, SegmentRef}; + +use super::outline::{KEY_OUTLINE, c32, node_outline, to_screen_path, walk_drawn}; +use super::overlays::{HandleLook, anchor_square, selected_anchor}; +use super::{BezPath, Color32, Node, NodeKind, Point, Pos2, Shape, Stroke, StrokeKind, SymbolBoxes, Xf, pos2, vec2}; + +/// How far a pan moves kept triangles before their object is built again. Screen coordinates are +/// `f32`, which holds a coordinate under 2^16 to within 1/256 px, and a point farther out than this +/// plus the width of a window stays off screen. +const MAX_PAN: f64 = 16_384.0; + +/// The most points and vertices kept. Past this the marks are dropped after the frame that built +/// them. +const MAX_KEPT: usize = 8_000_000; + +/// The most anchor square meshes kept, one per color, style and size in pixels. +const MAX_TEMPLATES: usize = 256; + +/// The farthest a handle's dot and its stroke reach from the end of its line, at the default size. +const HANDLE_REACH: f32 = 6.0; + +/// Everything that decides how marks look, apart from the object and the view's position. +#[derive(Clone, PartialEq)] +struct MarkLook { + zoom: f64, + rot: f64, + pixels_per_point: f32, + tessellation: TessellationOptions, + /// Disc texture coordinates scale with the size of the font texture. + font_image_size: [usize; 2], + direct: bool, + curvature: bool, + curve_points_from_tool: bool, + grow: f32, + handle_style: String, + segments: BTreeMap>, +} + +/// The options [`SelectionMarks::paint`] draws with. +#[derive(Clone, Copy)] +pub(super) struct MarkOptions<'a> { + /// Whether the tool edits anchors. + pub direct: bool, + /// Whether the tool is the Curvature tool. + pub curvature: bool, + /// Whether the Curvature tool draws its own points. + pub curve_points_from_tool: bool, + /// Selection & Anchor Display › Size and Handles. + pub look: HandleLook<'a>, + /// Selection & Anchor Display › Show handles when multiple anchors are selected. + pub all_handles: bool, +} + +/// [`Xf`] with its rotation worked out, split into a linear part and an offset. +struct ViewMap { + sin: f64, + cos: f64, + zoom: f64, + origin: Pos2, + center: [f64; 2], + offset: [f64; 2], +} + +impl ViewMap { + fn new(xf: &Xf) -> Self { + let (sin, cos) = xf.rot.sin_cos(); + let origin = xf.rect.center(); + let mut map = Self { sin, cos, zoom: xf.zoom, origin, center: [0.0; 2], offset: [0.0; 2] }; + map.center = map.view(xf.center); + map.offset = [f64::from(origin.x) - map.center[0], f64::from(origin.y) - map.center[1]]; + map + } + + /// `p` scaled and rotated by the view, without its position. + fn view(&self, p: Point) -> [f64; 2] { + let (x, y) = (p.x * self.zoom, p.y * self.zoom); + [x * self.cos - y * self.sin, x * self.sin + y * self.cos] + } + + fn screen(&self, v: [f64; 2]) -> Pos2 { + pos2(self.origin.x + (v[0] - self.center[0]) as f32, self.origin.y + (v[1] - self.center[1]) as f32) + } + + fn to_screen(&self, p: Point) -> Pos2 { + self.screen(self.view(p)) + } +} + +/// One run of an object's marks. +enum Mark { + /// Marks whose triangles keep their form when moved: polylines, discs and slanted lines. + /// `shapes` are in screen coordinates at the object's `base` and become `mesh` the first time + /// the run is on screen. Discs need the frame's own tessellator (`discs`). + Rigid { shapes: Vec, mesh: Mesh, bounds: egui::Rect, points: usize, discs: bool }, + /// Direction handles of one color, from [`ViewMap::view`]: the line from each anchor and the dot + /// at its end. They become `mesh` for the offset `at` the first time the run is on screen. With a + /// line that is, or is nearly, level or upright (`rounded`), `mesh` moves only by whole pixels, + /// and any other move draws the run again. + Handles { color: Color32, lines: Vec<[[f64; 2]; 2]>, rounded: bool, mesh: Mesh, at: [f64; 2], bounds: ([f64; 2], [f64; 2]) }, + /// Anchor squares in one style, at positions from [`ViewMap::view`]. + Squares { color: Color32, filled: bool, size: f32, at: Vec<[f64; 2]> }, + /// A line that is, or is nearly, level or upright: the tessellator rounds those to whole pixels. + Segment { a: [f64; 2], b: [f64; 2], stroke: Stroke }, +} + +impl Mark { + fn kept(&self) -> usize { + match self { + Self::Rigid { mesh, points, .. } => mesh.vertices.len().max(*points), + Self::Handles { lines, mesh, .. } => mesh.vertices.len() + lines.len(), + Self::Squares { at, .. } => at.len(), + Self::Segment { .. } => 1, + } + } +} + +/// Builds a list of [`Mark`]s, joining neighbors that can be drawn as one run. +struct MarkList<'a> { + map: &'a ViewMap, + marks: Vec, +} + +impl<'a> MarkList<'a> { + fn new(map: &'a ViewMap) -> Self { + Self { map, marks: vec![] } + } + + fn rigid(&mut self, shape: Shape) { + let rect = shape.visual_bounding_rect(); + let count = match &shape { + Shape::Path(path) => path.points.len(), + _ => 1, + }; + let disc = matches!(shape, Shape::Circle(_)); + if let Some(Mark::Rigid { shapes, bounds, points, discs, .. }) = self.marks.last_mut() { + *bounds = bounds.union(rect); + *points += count; + *discs |= disc; + shapes.push(shape); + } else { + self.marks.push(Mark::Rigid { shapes: vec![shape], mesh: Mesh::default(), bounds: rect, points: count, discs: disc }); + } + } + + /// A direction handle: its line from `from` and the dot at `to`. + fn handle(&mut self, from: Point, to: Point, color: Color32) { + let (a, b) = (self.map.view(from), self.map.view(to)); + let is_rounded = is_rounded_line(self.map.screen(a), self.map.screen(b)); + let least = [a[0].min(b[0]), a[1].min(b[1])]; + let most = [a[0].max(b[0]), a[1].max(b[1])]; + if let Some(Mark::Handles { color: c, lines, rounded, bounds, .. }) = self.marks.last_mut() + && *c == color + { + *bounds = ([bounds.0[0].min(least[0]), bounds.0[1].min(least[1])], [bounds.1[0].max(most[0]), bounds.1[1].max(most[1])]); + *rounded |= is_rounded; + lines.push([a, b]); + } else { + self.marks.push(Mark::Handles { + color, + lines: vec![[a, b]], + rounded: is_rounded, + mesh: Mesh::default(), + at: [0.0; 2], + bounds: (least, most), + }); + } + } + + fn outline(&mut self, path: &BezPath, xf: &Xf, stroke: Stroke) { + for line in to_screen_path(path, xf) { + self.rigid(Shape::line(line, stroke)); + } + } + + fn square(&mut self, p: Point, color: Color32, filled: bool, size: f32) { + let v = self.map.view(p); + if let Some(Mark::Squares { color: c, filled: f, size: s, at }) = self.marks.last_mut() + && (*c, *f, *s) == (color, filled, size) + { + at.push(v); + } else { + self.marks.push(Mark::Squares { color, filled, size, at: vec![v] }); + } + } + + fn line(&mut self, from: Point, to: Point, stroke: Stroke) { + let (a, b) = (self.map.view(from), self.map.view(to)); + let (sa, sb) = (self.map.screen(a), self.map.screen(b)); + if is_rounded_line(sa, sb) { + self.marks.push(Mark::Segment { a, b, stroke }); + } else { + self.rigid(Shape::line_segment([sa, sb], stroke)); + } + } +} + +/// Whether a line from `a` to `b` is, or after a move could be, exactly level or upright. +fn is_rounded_line(a: Pos2, b: Pos2) -> bool { + (a.x - b.x).abs() < 0.002 || (a.y - b.y).abs() < 0.002 +} + +/// The marks of one object (or the key object's outline), and what they were built from. +struct ObjectMarks { + /// Holds the allocation, so that no later node can have this address. While it exists, + /// `Arc::make_mut` moves an edited node to a new address. + node: Weak, + color: Color32, + partial: Option>, + /// The document and revision, for an object that measures symbols' art. + symbols: Option<(u64, u64)>, + /// [`ViewMap::offset`] when the marks were built. + base: [f64; 2], + marks: Vec, + handles: Vec, + /// The anchors whose handles `handles` shows. + with_handles: usize, + seen: u64, +} + +impl ObjectMarks { + fn current(&self, node: &Arc, color: Color32, partial: Option<&BTreeSet>, source: (u64, u64), map: &ViewMap) -> bool { + Weak::as_ptr(&self.node) == Arc::as_ptr(node) + && self.color == color + && self.partial.as_ref() == partial + && self.symbols.is_none_or(|s| s == source) + && (map.offset[0] - self.base[0]).abs() <= MAX_PAN + && (map.offset[1] - self.base[1]).abs() <= MAX_PAN + } +} + +/// The node `id` as held in the document, for its identity. +fn node_arc(doc: &vectorcraft_doc::Document, id: NodeId) -> Option<&Arc> { + let path = doc.index_path(id)?; + let (first, rest) = path.split_first()?; + let mut node = doc.layers.get(*first)?; + for i in rest { + node = node.children()?.get(*i)?; + } + Some(node) +} + +/// A square's triangles, relative to the corner it was rounded to. +struct Template { + color: Color32, + filled: bool, + size: f32, + pixels: (i32, i32), + mesh: Mesh, +} + +/// A handle's dot in one color, around the origin. +struct Dot { + color: Color32, + mesh: Mesh, +} + +/// What one anchor square draws: the fill, then the stroke of a hollow one. +fn square_shapes(rect: egui::Rect, color: Color32, filled: bool) -> [Option; 2] { + if filled { + [Some(RectShape::filled(rect, 0.0, color)), None] + } else { + [Some(RectShape::filled(rect, 0.0, Color32::WHITE)), Some(RectShape::stroke(rect, 0.0, Stroke::new(1.0, color), StrokeKind::Inside))] + } +} + +/// `shapes` as the frame's tessellator draws them, with nothing culled. +fn tessellated(ctx: &egui::Context, shapes: Vec, pixels_per_point: f32) -> Mesh { + let clipped = shapes.into_iter().map(|shape| ClippedShape { + clip_rect: egui::Rect::EVERYTHING, + shape, + transform_after_tessellation: TSTransform::IDENTITY, + }); + let primitives = ctx.tessellate(clipped.collect(), pixels_per_point); + primitives + .into_iter() + .find_map(|c| match c.primitive { + Primitive::Mesh(mesh) => Some(mesh), + Primitive::Callback(_) => None, + }) + .unwrap_or_default() +} + +/// Joins marks into the frame's mesh. +struct Composer<'a> { + ctx: &'a egui::Context, + map: &'a ViewMap, + clip: egui::Rect, + pixels_per_point: f32, + /// Squares this size or smaller are drawn as lines, which round differently: see + /// [`Tessellator::tessellate_rect`]. + thin: f32, + /// Draws lines and squares that rounding keeps from being reused, culled by `clip`. + tessellator: Tessellator, + /// Draws what is kept for later frames, with nothing culled. Like `tessellator`, it has no discs, + /// so neither draws circles. + keeper: Tessellator, + /// Draws filled squares without the soft edge, when that edge falls between pixel centers: on a + /// square rounded to whole pixels, each pixel center is then fully in or fully out. + crisp: Option, + /// Whether the tessellator rounds rectangles to whole pixels, as [`Self::squares`] assumes. + rounds_rects: bool, + look: HandleLook<'a>, + templates: &'a mut Vec