From 7215594abc3e4f3ba8fbc796c95873169f3a5bec Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:33:41 -0500 Subject: [PATCH 01/16] Graphic Styles: the list fills a taller panel above its footer (#2992) --- crates/ui-egui/src/panels/graphic_styles.rs | 43 ++++++++++++++++++++- docs/ui-parity.md | 4 +- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 0c702636ec..fe38851b44 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -465,9 +465,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let sel = selected(ui.ctx(), &doc); let view: View = pstate(ui.ctx(), "gs-view"); let mut ev = Events::default(); + // In a sized panel the list fills the space above the footer (#2992), as the Artboards list + // does; elsewhere it keeps its 220 pt. + let fixed = widgets::panel_footer_bottom(ui).is_some(); + let list_height = widgets::panel_list_height(ui, 220.0, 2.0 + ui.spacing().item_spacing.y); widgets::list_box(ui, |ui| { - let out = egui::ScrollArea::vertical().id_salt("gs-scroll").max_height(220.0).show(ui, |ui| { - ui.set_min_height(120.0); + let scroll = egui::ScrollArea::vertical() + .id_salt("gs-scroll") + .max_height(list_height) + .min_scrolled_height(list_height.min(64.0)) + .auto_shrink([false, !fixed]); + let out = scroll.show(ui, |ui| { + ui.set_min_height(if fixed { list_height.min(120.0) } else { 120.0 }); ui.set_width(ui.available_width()); if doc.graphic_styles.is_empty() { super::empty_state(ui, "dc-graphic-styles", tl!("No graphic styles"), tl!("Select styled art and click New Graphic Style.")); @@ -847,6 +856,36 @@ mod tests { app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect() } + /// In a panel made taller the list fills the space above its footer instead of staying 220 pt + /// tall with blank space below it (#2992); outside a sized panel it keeps that height. + #[test] + fn the_list_fills_a_tall_panel_above_its_footer() { + let mut app = app(); + for i in 0..30 { + app.session.execute("graphicStyle.new", &json!({"name": format!("Style {i:02}")})).unwrap(); + } + let ctx = egui::Context::default(); + set_pstate(&ctx, "gs-view", View::LargeList); + // The clip rect of the first style's name: the list's visible area. + let mut list = |bottom: Option| { + let mut clip = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 700.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let info = egui::UiStackInfo::default().with_tag_value(widgets::PANEL_FOOTER, bottom); + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(info), |ui| show(&mut app, ui)); + }); + out.textures_delta.clear(); + clip = out.shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Style 00")).map(|c| c.clip_rect); + } + clip.expect("the first style's row is drawn") + }; + let tall = list(Some(650.0)); + assert!(tall.height() > 400.0 && tall.bottom() <= 650.0 - 31.0, "the list fills the panel above its footer: {tall:?}"); + let loose = list(None); + assert!(loose.height() <= 220.5, "unsized, the list keeps its height: {loose:?}"); + } + #[test] fn list_view_names_the_styles_and_deletes_the_selected_ones_after_asking() { let mut app = app(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e5f9b1cf19..a208a43de6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992). Scores unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -165,6 +166,7 @@ tools, never checked side by side) isn't captured row by row. | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | | Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression | | Icon-strip flyout resizing from the top (storytold/craft-libs#1094) | done | Top edge (a 4 pt band over the tab strip) and the canvas-side top corner; the bottom and the icon-column edge stay; the moved top persists after reopening and workspace restore; `an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner` | +| Graphic Styles list fills a taller panel (#2992) | done | Docked, floating or in a resized flyout, the list takes the height above the panel's footer, as the Artboards list does; outside a sized panel it stays 220 pt; `the_list_fills_a_tall_panel_above_its_footer` | | Layers panel lists a blend's spine (#4820) | done | an open blend shows a `` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) | | Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` | | Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 | From 600302c3a5c37e20ff4e9bfc0c78ec2dda814724 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:51:35 -0500 Subject: [PATCH 02/16] Graphic Styles list (#2992): parity and roadmap revision rows --- docs/roadmap.md | 3 ++- docs/target-app-parity.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c78d258c9..93b0241e1a 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-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; 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) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -52,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5851d78ce..cf141f7d5e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -53,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches panel: its menu's Show Recent Colors hides and shows the panel's Recent Colors row, checked by default and kept between sessions apart from the Color panel's (#6617); scores unchanged. | | 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | From 432fe0362fab8a3cd5600b8f7c11680f743eb131 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:45:12 -0500 Subject: [PATCH 03/16] Graphic Styles list (#2992): M3 and area row notes --- docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 93b0241e1a..d229b54a94 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -148,7 +148,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 (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; 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), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 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 copies transformed character dimensions (#6404) and 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 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and 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 | Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8e639a3ad7..b9d1e91416 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -497,7 +497,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | -| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | +| Appearance, transparency, graphic styles, masks | 5 | 98% | Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | From 1fe4529248b3812cea0e8d22a4c97d3cc91d365b Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:28:16 -0500 Subject: [PATCH 04/16] Roadmap M3 row: the panel-list notes go in the Status cell (#2992, #1638) --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6c5f1e3ecb..dc64c58717 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,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 (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; 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), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 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 copies transformed character dimensions (#6404) and 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 | Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and 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 Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | From b8877455be07f84c653f6be24977ba0b21d0643c Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:28:36 -0500 Subject: [PATCH 05/16] Roadmap M3 row: separate the moved notes from the status text --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index dc64c58717..039bc318e1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,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 (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; 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), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 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 copies transformed character dimensions (#6404) and 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 Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); | 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and 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; Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | From 29649a9d35490c33510525c23b99d4e9ad3b7348 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:34:42 -0800 Subject: [PATCH 06/16] .ai import: graphic styles of one paint open with their fill and stroke A style written as one paint (`/SimpleStyle` with its paint operators) was read as a style with no fill and no stroke. A graphic style library listed it, and applying it cleared the object's fill and stroke. Such a style now reads as the fill and stroke its `stroke fill Xd` turns on, with the stroke's width, caps, joins, miter limit and dashes, and its `Xy` transparency. A style whose paint is not read here (a gradient or pattern fill, a pattern stroke, no `Xd`) is left out of the library instead of coming in empty. Part of #3349. --- .../engine/src/cmd/fileio/tests_aiimport.rs | 32 +++++- crates/engine/tests/import_fuzz.rs | 27 +++++ crates/eps/src/import/ai/read/ops.rs | 7 +- crates/eps/src/import/ai/style.rs | 104 ++++++++++++++++-- crates/eps/src/import/ai/style/tests.rs | 33 ++++++ 5 files changed, 187 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 0f866c6588..fafcc12215 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -424,25 +424,45 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() { /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n" ) }; + // Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a gradient fill, + // which isn't read, so it is left out rather than listed with no paint. + let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n"); let styles = format!( - "%AI9_BeginArtStyles\n{}{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n%AI9_EndArtStyleList\n", + "%AI9_BeginArtStyles\n{}{}{}{}{}%AI9_EndArtStyles\n\ + %AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n(Red outline)\n(Sky fill)\n%AI9_EndArtStyleList\n", style("Blue", "0 0 0 0 0 0 1 Xa\n"), style("Green", "0 0 0 0 0 1 0 Xa\n"), - style("Red", "0 0 0 0 1 0 0 Xa\n") + style("Red", "0 0 0 0 1 0 0 Xa\n"), + one_paint("Red outline", "0 R\n0 1 1 0 K\n0 J 0 j 2 w 10 M []0 d\n1 0 Xd\n"), + one_paint("Sky fill", "0 O\n0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n") ); let data = ai::editing_data(200.0, 100.0, &styles); let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"); let mut s = Session::new(); let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fills.ai"}); let r = s.execute("graphicStyle.loadLibrary", &p).unwrap(); - assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(3)), "{r}"); - let lib = s.execute("graphicStyle.library", &json!({"library": r["library"]})).unwrap(); + assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(4)), "{r}"); + let library = r["library"].clone(); + let lib = s.execute("graphicStyle.library", &json!({"library": library})).unwrap(); let names: Vec<&str> = lib["styles"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect(); - assert_eq!(names, ["Red", "Green", "Blue"], "{lib}"); + assert_eq!(names, ["Red", "Green", "Blue", "Red outline"], "{lib}"); + // The outline style strokes a rectangle red, 2 pt wide, and leaves it unfilled. + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["Red outline"], "apply": true, "ids": [id.0]})).unwrap(); + assert_eq!(r["added"], json!(["Red outline"]), "{r}"); + let doc = &s.doc().unwrap().doc; + let look = &doc.node(id).unwrap().appearance; + assert!(look.fill().is_none(), "{look:?}"); + let strokes: Vec<_> = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).collect(); + assert_eq!(strokes.len(), 1, "{look:?}"); + let stroke = look.stroke().unwrap(); + assert_eq!((stroke.paint.color(), stroke.width), (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0), "{look:?}"); + assert_eq!(doc.graphic_style("Red outline").map(|g| &g.appearance), Some(&**look)); let r = open(&mut s, "fills.ai", &file, json!({})).unwrap(); let doc = &s.doc().unwrap().doc; let styles: Vec<(&str, u32)> = doc.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect(); - assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}"); + assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3), ("Red outline", 4)], "{r}"); } /// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4688845de4..1a13af2868 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4217,6 +4217,33 @@ proptest! { survive("style library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; } + /// Graphic style libraries whose styles are one paint (`/SimpleStyle`): any paint operators in + /// any order, with any operands or none, `Xd` missing or repeated, and the paint left open. + #[test] + fn ai_style_libraries_of_one_paint_styles_never_panic( + ops in prop::collection::vec(prop_oneof![ + prop::sample::select(vec![ + "0 R", "0 O", "0 1 1 0 K", "1 0 0 0 k", "0 0 0 0 1 0 0 Xa", "0 0 0 0 1 0 0 XA", "0.5 G", "0 g", "K", "Xa", + "0 J 0 j 2 w 10 M []0 d", "-1 w", "1e400 w", "0 M", "[3 -1]0 d", "[]0 d", + "1 0 Xd", "0 1 Xd", "1 1 Xd", "0 0 Xd", "1 Xd", "Xd", "0 1 0 0 0 Xy", "1 0.5 1 1 0 Xy", "Xy", + "0 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "0 BB", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P", "0 XR", + ]).prop_map(str::to_string), + "[ -~]{0,12}", + ], 0..12), + closed in any::(), + ) { + use vectorcraft_testkit::ai; + let close = if closed { "/Paint ;\n/Def ;\n" } else { "" }; + let header = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(A) /Name ,\n/SimpleStyle :\n{}\n{close}/KnownStyle :\n(B) /Name ,\n/SimpleStyle :\n1 0 Xd\n/Paint ;\n/Def ;\n\ + %AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(A)\n(B)\n%AI9_EndArtStyleList\n", + ops.join("\n") + ); + let library = ai::editing_data(200.0, 100.0, &header); + survive("one-paint style library", || Some(vectorcraft_eps::layered_ai(library.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// Objects on Path records of any shape: ids in any order, repeated or missing, any number of /// objects, pivots and angles out of range or not finite, spacings, order tags that disagree, /// groups nested or cut short, and versioned content left open. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index bc58858b91..9f6a1dd6af 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1231,13 +1231,18 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p /// A graphic style's appearance from art style `st`, on no object: `None` when the style isn't one /// this reads, or paints with a gradient or a pattern (placed on each object as its drawn look has them). +/// A style that is one paint is its fill and stroke ([`crate::import::ai::style::ArtStyle::one_paint`]). pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) -> Option { use crate::import::ai::style::StyleItem; if !st.plain { return None; } + let style_items = match &st.one_paint { + Some(one) => one.as_ref().ok()?, + None => &st.items, + }; let mut items = vec![]; - for (n, item) in st.items.iter().enumerate() { + for (n, item) in style_items.iter().enumerate() { let mut layer = match item { StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), // A graphic style's brush isn't checked against the file's brushes: it is left out. diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 6a75837733..f9a0d0df4b 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -32,6 +32,10 @@ //! 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 style with no filters is one paint (`/SimpleStyle :` … `/Paint ;` in place of `/ActiveStyle`): +//! the fill's and stroke's colours and the stroke's style as an object's own paint operators write +//! them, `stroke fill Xd` turning each on, and an `Xy` for its transparency. +//! //! 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`]). @@ -206,6 +210,11 @@ pub(super) struct ArtStyle { /// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such /// styles for their fills and strokes (`ate`). `None` for any other paint or style. pub simple_pattern: Option, + /// A style that is one paint: its fill and stroke, bottom first, each there when its + /// `stroke fill Xd` turns it on (none when neither is on). `Err` when it turns on a fill or + /// stroke whose paint isn't read here (a gradient, a pattern, a colour that isn't read) or has + /// no `Xd`. `None` for a style with filters, whose paint is [`Self::items`]. + pub one_paint: Option, ()>>, } impl ArtStyle { @@ -412,12 +421,22 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap> = None; - let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option| { + // The same style's paint operators so far. + let mut one_paint: Option = None; + let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option, one_paint: Option| { if let Some(name) = name { // Only a style that is one paint holds a pattern for type: one with filters as well // paints with more than that. let simple_pattern = simple_pattern.filter(|_| filters.is_empty()); - styles.insert(name, ArtStyle { simple_pattern, ..style(filters) }); + let mut st = ArtStyle { simple_pattern, ..style(filters) }; + if let Some(one) = one_paint.filter(|_| filters.is_empty()) { + if let Some((mode, opacity)) = one.paint.xy { + let (isolate, knockout) = one.paint.xy_group.unwrap_or((false, Knockout::Off)); + st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout)); + } + st.one_paint = Some(one.items()); + } + styles.insert(name, st); } filters.clear(); }; @@ -454,12 +473,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap match n { b"KnownStyle" => { - finish(name.take(), &mut filters, simple.take().flatten()); + finish(name.take(), &mut filters, simple.take().flatten(), one_paint.take()); operands.clear(); open.clear(); (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } - b"SimpleStyle" if filters.is_empty() => simple = Some(None), + b"SimpleStyle" if filters.is_empty() => (simple, one_paint) = (Some(None), Some(OnePaint::default())), b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), @@ -559,7 +578,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap HashMap Option<(String, Option<[f6 } /// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style -/// that is one paint (`simple`, while one is read). -fn style_paint_op(filters: &mut [Filter], simple: &mut Option>, op: &str, vals: &[V], names: &mut Names) { +/// that is one paint (`simple` and `one_paint`, while one is read). +fn style_paint_op( + filters: &mut [Filter], + simple: &mut Option>, + one_paint: &mut Option, + op: &str, + vals: &[V], + names: &mut Names, +) { if let Some(f) = filters.last_mut() { paint_op(f, op, vals, names); - } else if let Some(paint) = simple.as_mut() { + return; + } + if let Some(paint) = simple.as_mut() { simple_paint_op(paint, op, vals, names); } + if let Some(one) = one_paint.as_mut() { + one.op(op, vals, names); + } +} + +/// The paint operators of a style that is one paint (`/SimpleStyle :` … `/Paint ;`), read as a +/// fill's or stroke's style reads them. +#[derive(Default)] +struct OnePaint { + paint: Filter, + /// Its `stroke fill Xd` was read. + xd: bool, + /// It strokes with a pattern (`P`). + stroke_pattern: bool, +} + +impl OnePaint { + fn op(&mut self, op: &str, vals: &[V], names: &mut Names) { + match op { + "Xd" => self.xd |= vals.iter().filter_map(V::num).count() >= 2, + "P" => self.stroke_pattern = true, + _ => {} + } + paint_op(&mut self.paint, op, vals, names); + } + + /// Its fill and stroke ([`ArtStyle::one_paint`]). + fn items(&self) -> Result, ()> { + let f = &self.paint; + if !self.xd { + return Err(()); + } + let (stroked, filled) = f.paints; + let mut items = vec![]; + if filled { + match &f.fill { + Some(p) if !f.gradient && f.pattern.is_none() => items.push(StyleItem::Fill(p.clone())), + _ => return Err(()), + } + } + if stroked { + match &f.stroke { + Some(p) if !self.stroke_pattern => items.push(StyleItem::Stroke { + paint: p.clone(), + width: f.width, + cap: f.cap, + join: f.join, + miter: f.miter, + dash: f.dash.clone(), + profile: None, + align: StrokeAlign::Center, + brush: None, + }), + _ => return Err(()), + } + } + Ok(items) + } } /// A paint operator of a style that is one paint: a pattern (`p`) is kept as its diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index c6debd42f9..a1aa2b3d28 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1512,3 +1512,36 @@ fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}"); } } + +/// A style that is one paint reads as the fill and stroke its `Xd` turns on, in the paint's colours +/// and the stroke's style; one that turns on a paint not read here, or has no `Xd`, isn't read. +#[test] +fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { + let one = |paint: &str| { + let data = format!("/KnownStyle :\n(S) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n"); + art_styles_in(data.as_bytes(), &mut Names::default()).remove("S").and_then(|s| s.one_paint) + }; + let stroke = "0 R\n0 1 1 0 K\n1 J 2 j 3 w 4 M [2 1]0 d\n"; + let Some(Ok(items)) = one(&format!("0 O\n0 0 1 0 k\n{stroke}1 1 Xd\n")) else { panic!("{:?}", one(stroke)) }; + let [StyleItem::Fill(fill), StyleItem::Stroke { paint, width, cap, join, miter, dash, profile: None, align: StrokeAlign::Center, brush: None }] = + items.as_slice() + else { + panic!("{items:?}") + }; + assert_eq!((fill.color(), paint.color()), (Some(Color::cmyk(0.0, 0.0, 1.0, 0.0)), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)))); + assert_eq!((*width, *cap, *join, *miter), (3.0, LineCap::Round, LineJoin::Bevel, 4.0)); + assert_eq!(dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0])); + // Only what `Xd` turns on; nothing when it turns on neither. + assert!(matches!(one(&format!("0 0 1 0 k\n{stroke}1 0 Xd\n")), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); + assert_eq!(one(&format!("0 0 1 0 k\n{stroke}0 0 Xd\n")), Some(Ok(vec![]))); + // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a paint turned on with no colour. + for paint in [ + format!("0 0 1 0 k\n{stroke}"), + "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n1 0 Xd\n".to_string(), + "0 0 1 0 k\n1 1 Xd\n".to_string(), + ] { + assert_eq!(one(&paint), Some(Err(())), "{paint}"); + } +} From 149f4243b235219628aa8ece0b5f2c16a6613b7e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:08:22 -0800 Subject: [PATCH 07/16] Place: DCS transparency interactions look only at what clipping paths show Show DCS Transparency Interactions compared full object bounds, so a transparent object over the part of a DCS image that its clipping path hides marked the image. Bounds are now cut to the clipping paths of the clip groups and clipping layers that hold each object. --- crates/engine/src/cmd/links.rs | 29 ++++++++++++++++++++++------- crates/engine/src/tests/dcs.rs | 26 ++++++++++++++++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 720a906462..f480a49e8c 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1939,15 +1939,21 @@ fn status_rank(status: &str) -> usize { /// holds them) or overlap an object that is, as Show DCS Transparency Interactions marks them. fn dcs_transparency(d: &Document) -> BTreeSet { /// Each visible DCS image's id, bounds and whether it is transparent, and the bounds of each - /// other visible object with its own transparency. - fn walk(d: &Document, n: &Node, held: bool, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec) { + /// other visible object with its own transparency; bounds are cut to `clip`, the bounds of the + /// clipping paths of the clip groups and layers holding the object. + fn walk(d: &Document, n: &Node, held: bool, clip: Option, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec) { if !n.visible { return; } let own = n.shows_own_transparency(); + // The part of the object that shows through `clip`; none when it is clipped away. + let shown = n.geometric_bounds().and_then(|b| match clip { + Some(c) => Some(c.intersect(b)).filter(|i| i.area() > 0.0), + None => Some(b), + }); match &n.kind { NodeKind::Image(im) if d.images.get(&im.key).is_some_and(ImageBlob::is_separated) => { - if let Some(b) = n.geometric_bounds() { + if let Some(b) = shown { images.push((n.id, b, held || own)); // A transparent DCS image is an object with its own transparency for the // others it overlaps. @@ -1956,16 +1962,25 @@ fn dcs_transparency(d: &Document) -> BTreeSet { } } } - _ if own => see_through.extend(n.geometric_bounds()), + _ if own => see_through.extend(shown), _ => {} } - for c in n.children().into_iter().flatten() { - walk(d, c, held || own, images, see_through); + let Some(children) = n.children() else { return }; + // A clip group's or clipping layer's first child clips the others. + let clip = match &n.kind { + NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. } => match children.first().and_then(|c| c.geometric_bounds()) { + Some(c) => Some(clip.map_or(c, |o| o.intersect(c))), + None => clip, + }, + _ => clip, + }; + for c in children { + walk(d, c, held || own, clip, images, see_through); } } let (mut images, mut see_through) = (vec![], vec![]); for l in &d.layers { - walk(d, l, false, &mut images, &mut see_through); + walk(d, l, false, None, &mut images, &mut see_through); } images .into_iter() diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 2801432a0a..271df2e106 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -657,6 +657,32 @@ fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() { assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false"); } +#[test] +fn dcs_transparency_looks_only_at_the_part_of_an_image_its_clipping_path_shows() { + let dir = temp_dir("transparency-clipped"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let image = image_of(&s, &r["ids"][0]).id; + let (clip, all) = { + let doc = &s.doc().unwrap().doc; + let NodeKind::Group { children, clip: true } = &doc.node(group).unwrap().kind else { panic!("a clipping group") }; + (children[0].geometric_bounds().unwrap(), doc.node(image).unwrap().geometric_bounds().unwrap()) + }; + assert!(all.x1 - clip.x1 > 2.0, "the file's clipping path shows the left half: {clip:?} of {all:?}"); + // A half-transparent square over the part of the image the clipping path hides. + let x = (clip.x1 + all.x1) / 2.0 - 1.0; + let over = s.execute("shape.rectangle", &json!({"x": x, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap(); + s.execute("transparency.set", &json!({"ids": [over], "opacity": 50})).unwrap(); + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + assert!(!marked(&mut s), "the square is over the hidden part only"); + s.execute("transparency.set", &json!({"ids": [over], "opacity": 100})).unwrap(); + let shown = s.execute("shape.rectangle", &json!({"x": clip.x0, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap(); + s.execute("transparency.set", &json!({"ids": [shown], "opacity": 50})).unwrap(); + assert!(marked(&mut s), "a square over the part that shows"); +} + #[test] fn dcs_transparency_counts_a_transparent_dcs_image_over_another() { let dir = temp_dir("transparency-two"); diff --git a/docs/mcp.md b/docs/mcp.md index 20e37222c6..cf7bbbf60a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6705,7 +6705,8 @@ DCS Transparency Interactions, off by default) adds `dcsTransparency: true` to t and the panel shows a warning mark on them. VectorCraft's rule for it: the image, or a group or layer holding it, has an opacity below 100%, a blend mode other than Normal, an opacity mask or an effect, or its bounds overlap the bounds of another visible object that has one of those or a gradient with a transparent stop (in a fill, a stroke or -a character's fill or stroke); a DCS image with transparency of its own counts as such an object. The check looks at +a character's fill or stroke); a DCS image with transparency of its own counts as such an object. Bounds are cut to the +bounds of the clipping paths of the clip groups and clipping layers holding an object. The check looks at every object in the layers each time the list is read. A `dcsTransparency` that is not true or false is an error. `links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file From ec892d4218bf7d906b80111ec0c0e0ef724a06ef Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:50:30 -0800 Subject: [PATCH 08/16] .ai import: use American spelling in the one-paint style comments --- crates/eps/src/import/ai/style.rs | 4 ++-- crates/eps/src/import/ai/style/tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index f9a0d0df4b..43e457100f 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -33,7 +33,7 @@ //! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency. //! //! A style with no filters is one paint (`/SimpleStyle :` … `/Paint ;` in place of `/ActiveStyle`): -//! the fill's and stroke's colours and the stroke's style as an object's own paint operators write +//! the fill's and stroke's colors and the stroke's style as an object's own paint operators write //! them, `stroke fill Xd` turning each on, and an `Xy` for its transparency. //! //! A stroke with a Calligraphic or Art brush is its own basic filter, `(Adobe Calligraphic Brush @@ -212,7 +212,7 @@ pub(super) struct ArtStyle { pub simple_pattern: Option, /// A style that is one paint: its fill and stroke, bottom first, each there when its /// `stroke fill Xd` turns it on (none when neither is on). `Err` when it turns on a fill or - /// stroke whose paint isn't read here (a gradient, a pattern, a colour that isn't read) or has + /// stroke whose paint isn't read here (a gradient, a pattern, a color that isn't read) or has /// no `Xd`. `None` for a style with filters, whose paint is [`Self::items`]. pub one_paint: Option, ()>>, } diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index a1aa2b3d28..2ada369b28 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1513,7 +1513,7 @@ fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { } } -/// A style that is one paint reads as the fill and stroke its `Xd` turns on, in the paint's colours +/// A style that is one paint reads as the fill and stroke its `Xd` turns on, in the paint's colors /// and the stroke's style; one that turns on a paint not read here, or has no `Xd`, isn't read. #[test] fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { @@ -1534,7 +1534,7 @@ fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { // Only what `Xd` turns on; nothing when it turns on neither. assert!(matches!(one(&format!("0 0 1 0 k\n{stroke}1 0 Xd\n")), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); assert_eq!(one(&format!("0 0 1 0 k\n{stroke}0 0 Xd\n")), Some(Ok(vec![]))); - // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a paint turned on with no colour. + // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a paint turned on with no color. for paint in [ format!("0 0 1 0 k\n{stroke}"), "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(), From a1f6eaed7776ac301a49ab31d2d1c4f818d96f07 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:25:12 -0800 Subject: [PATCH 09/16] .ai import: a one-paint style reads the last fill and stroke paint A one-paint style took a gradient (`Bg`) after a solid stroke color as the solid stroke, and left out a style whose solid fill color came after a pattern or a gradient. Each later paint operator now replaces the one before it on its own side, and a gradient marks both sides as not read, since its operands do not say which one it paints. --- crates/eps/src/import/ai/style.rs | 20 +++++++++++++++----- crates/eps/src/import/ai/style/tests.rs | 11 ++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 43e457100f..27657f570b 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -776,15 +776,25 @@ struct OnePaint { paint: Filter, /// Its `stroke fill Xd` was read. xd: bool, - /// It strokes with a pattern (`P`). - stroke_pattern: bool, + /// Its last fill paint is a pattern (`p`) or may be a gradient (`Bg`). + fill_unread: bool, + /// Its last stroke paint is a pattern (`P`) or may be a gradient (`Bg`). + stroke_unread: bool, } impl OnePaint { fn op(&mut self, op: &str, vals: &[V], names: &mut Names) { match op { "Xd" => self.xd |= vals.iter().filter_map(V::num).count() >= 2, - "P" => self.stroke_pattern = true, + "p" => self.fill_unread = true, + "P" => self.stroke_unread = true, + // Read as either one's, since its operands don't say which it paints. + "Bg" => (self.fill_unread, self.stroke_unread) = (true, true), + // A color replaces what came before it; one that isn't read leaves none. + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "xa" | "xx" | "xk" | "xs" | "xz" => { + (self.fill_unread, self.paint.fill) = (false, None); + } + "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" => (self.stroke_unread, self.paint.stroke) = (false, None), _ => {} } paint_op(&mut self.paint, op, vals, names); @@ -800,13 +810,13 @@ impl OnePaint { let mut items = vec![]; if filled { match &f.fill { - Some(p) if !f.gradient && f.pattern.is_none() => items.push(StyleItem::Fill(p.clone())), + Some(p) if !self.fill_unread => items.push(StyleItem::Fill(p.clone())), _ => return Err(()), } } if stroked { match &f.stroke { - Some(p) if !self.stroke_pattern => items.push(StyleItem::Stroke { + Some(p) if !self.stroke_unread => items.push(StyleItem::Stroke { paint: p.clone(), width: f.width, cap: f.cap, diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 2ada369b28..29a6e996bb 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1534,12 +1534,21 @@ fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { // Only what `Xd` turns on; nothing when it turns on neither. assert!(matches!(one(&format!("0 0 1 0 k\n{stroke}1 0 Xd\n")), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); assert_eq!(one(&format!("0 0 1 0 k\n{stroke}0 0 Xd\n")), Some(Ok(vec![]))); - // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a paint turned on with no color. + // The last paint of each one counts: a solid color after a pattern or a gradient replaces it. + let solid_fill = |before: &str| one(&format!("{before}\n0 0 1 0 k\n0 1 Xd\n")); + for before in ["(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"] { + assert!(matches!(solid_fill(before), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)])), "{before}"); + } + let solid_stroke = one("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n0 1 1 0 K\n1 0 Xd\n"); + assert!(matches!(solid_stroke, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); + // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a gradient stroke after a solid one, a paint + // turned on with no color. for paint in [ format!("0 0 1 0 k\n{stroke}"), "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(), "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(), "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n1 0 Xd\n".to_string(), + "0 R\n0 1 1 0 K\nBb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n".to_string(), "0 0 1 0 k\n1 1 Xd\n".to_string(), ] { assert_eq!(one(&paint), Some(Err(())), "{paint}"); From ecb994540c75aa2e76d5317fba9090e77332f106 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:36:18 -0800 Subject: [PATCH 10/16] .ai import: a one-paint style's gradient paints the side its flag names A gradient instance in a one-paint style marked both the fill and the stroke as not read, so a style with a solid fill and a stroke gradient was left out even when its `Xd` turns on only the fill. The instance's flag (`1 Bb` for the stroke) now says which side the gradient paints, as the object reader reads it; a gradient outside an instance still marks both. --- crates/eps/src/import/ai/style.rs | 13 +++++++++++-- crates/eps/src/import/ai/style/tests.rs | 5 ++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 27657f570b..482c172386 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -780,6 +780,8 @@ struct OnePaint { fill_unread: bool, /// Its last stroke paint is a pattern (`P`) or may be a gradient (`Bg`). stroke_unread: bool, + /// Inside a gradient instance (`flag Bb` … `BB`): whether it paints the stroke (flag 1). + instance: Option, } impl OnePaint { @@ -788,8 +790,15 @@ impl OnePaint { "Xd" => self.xd |= vals.iter().filter_map(V::num).count() >= 2, "p" => self.fill_unread = true, "P" => self.stroke_unread = true, - // Read as either one's, since its operands don't say which it paints. - "Bg" => (self.fill_unread, self.stroke_unread) = (true, true), + // The flag of the instance it is in says which one it paints, as an object's does + // (`Instance::begin` in `paint.rs`); outside one it is read as either one's. + "Bb" => self.instance = Some(vals.iter().filter_map(V::num).next_back() == Some(1.0)), + "BB" => self.instance = None, + "Bg" => match self.instance { + Some(true) => self.stroke_unread = true, + Some(false) => self.fill_unread = true, + None => (self.fill_unread, self.stroke_unread) = (true, true), + }, // A color replaces what came before it; one that isn't read leaves none. "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "xa" | "xx" | "xk" | "xs" | "xz" => { (self.fill_unread, self.paint.fill) = (false, None); diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 29a6e996bb..ea8428809c 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1541,6 +1541,9 @@ fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { } let solid_stroke = one("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n0 1 1 0 K\n1 0 Xd\n"); assert!(matches!(solid_stroke, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); + // A gradient instance's flag says which one it paints: a stroke gradient leaves a solid fill read. + let fill_and_stroke_gradient = one("0 0 1 0 k\n1 Bb\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 1 Xd\n"); + assert!(matches!(fill_and_stroke_gradient, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)]))); // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a gradient stroke after a solid one, a paint // turned on with no color. for paint in [ @@ -1548,7 +1551,7 @@ fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(), "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(), "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n1 0 Xd\n".to_string(), - "0 R\n0 1 1 0 K\nBb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n".to_string(), + "0 R\n0 1 1 0 K\n1 Bb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n".to_string(), "0 0 1 0 k\n1 1 Xd\n".to_string(), ] { assert_eq!(one(&paint), Some(Err(())), "{paint}"); From c11cb9c180084518d74cb2f3bcf11fac04906752 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:04:56 -0800 Subject: [PATCH 11/16] Place: test DCS transparency with clip groups, clipping layers and nested clips Adds a test with a half-transparent square that its own clip group, its clipping layer, or two nested clipping paths together hide, so the square no longer marks the DCS image under it. --- crates/engine/src/tests/dcs.rs | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 271df2e106..3f61388158 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -683,6 +683,62 @@ fn dcs_transparency_looks_only_at_the_part_of_an_image_its_clipping_path_shows() assert!(marked(&mut s), "a square over the part that shows"); } +#[test] +fn dcs_transparency_skips_a_transparent_object_its_own_clipping_paths_hide() { + let dir = temp_dir("transparency-clipped-over"); + let path = write(&dir, "pic.eps", &two_plates()); + // A session with the file placed, and the top-left corner of the image, inside the part its + // clipping path shows. + let placed = || { + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let b = image_of(&s, &r["ids"][0]).geometric_bounds().unwrap(); + (s, b.x0, b.y0) + }; + let rect = |s: &mut Session, x: f64, y: f64, width: f64| { + s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": width, "height": 2})).unwrap()["id"].as_u64().unwrap() + }; + let make = |s: &mut Session, ids: [u64; 2]| { + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap() + }; + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + // A half-transparent square over the part of the image that shows. + let square = |s: &mut Session, x: f64, y: f64| { + let id = rect(s, x, y, 2.0); + s.execute("transparency.set", &json!({"ids": [id], "opacity": 50})).unwrap(); + assert!(marked(s), "the square over the image"); + id + }; + + // Its own clip group's clipping path is beside it. + let (mut s, x, y) = placed(); + let over = square(&mut s, x, y); + let beside = rect(&mut s, x + 5.0, y, 2.0); + make(&mut s, [over, beside]); + assert!(!marked(&mut s), "a clip group that hides the square"); + + // Its layer's clipping path is beside it. + let (mut s, x, y) = placed(); + s.execute("layer.new", &json!({})).unwrap(); + square(&mut s, x, y); + rect(&mut s, x + 5.0, y, 2.0); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("layer.clippingMask.toggle", &json!({})).unwrap()["clip"].as_bool().unwrap()); + assert!(!marked(&mut s), "a clipping layer that hides the square"); + + // Each of two nested clipping paths shows part of the inner group, and only the inner one + // covers the square. + let (mut s, x, y) = placed(); + let over = square(&mut s, x, y); + let inner = rect(&mut s, x, y, 40.0); + let group = make(&mut s, [over, inner]); + assert!(marked(&mut s), "the inner clipping path shows the square"); + let outer = rect(&mut s, x + 10.0, y, 20.0); + make(&mut s, [group, outer]); + assert!(!marked(&mut s), "the two clipping paths together hide the square"); +} + #[test] fn dcs_transparency_counts_a_transparent_dcs_image_over_another() { let dir = temp_dir("transparency-two"); From fb7176565d6a9d12718a5dd877662aca81ca1ed7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:16:56 -0800 Subject: [PATCH 12/16] Tests: the one-paint style fuzz case applies the styles it loads The fuzz case also loads its .ai library with graphicStyle.loadLibrary, applies every style to the rich document's objects and then applies a red 2 pt stroke style read before the fuzzed one, and checks that the stroke style reaches the objects whenever the fuzzed text is only paint operators. --- crates/engine/tests/import_fuzz.rs | 37 +++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 1a13af2868..d6a76a772b 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4218,9 +4218,11 @@ proptest! { } /// Graphic style libraries whose styles are one paint (`/SimpleStyle`): any paint operators in - /// any order, with any operands or none, `Xd` missing or repeated, and the paint left open. + /// any order, with any operands or none, `Xd` missing or repeated, and the paint left open. The + /// library is also loaded in the Graphic Style Libraries panel and its styles applied to artwork. #[test] fn ai_style_libraries_of_one_paint_styles_never_panic( + // Each operator, and whether it is a paint operator rather than any text. ops in prop::collection::vec(prop_oneof![ prop::sample::select(vec![ "0 R", "0 O", "0 1 1 0 K", "1 0 0 0 k", "0 0 0 0 1 0 0 Xa", "0 0 0 0 1 0 0 XA", "0.5 G", "0 g", "K", "Xa", @@ -4228,20 +4230,43 @@ proptest! { "1 0 Xd", "0 1 Xd", "1 1 Xd", "0 0 Xd", "1 Xd", "Xd", "0 1 0 0 0 Xy", "1 0.5 1 1 0 Xy", "Xy", "0 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "0 BB", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P", "0 XR", - ]).prop_map(str::to_string), - "[ -~]{0,12}", + ]).prop_map(|op| (op.to_string(), true)), + "[ -~]{0,12}".prop_map(|text| (text, false)), ], 0..12), closed in any::(), ) { use vectorcraft_testkit::ai; let close = if closed { "/Paint ;\n/Def ;\n" } else { "" }; + // C, a red 2 pt stroke, comes first, so that it reads whatever A holds. let header = format!( - "%AI9_BeginArtStyles\n/KnownStyle :\n(A) /Name ,\n/SimpleStyle :\n{}\n{close}/KnownStyle :\n(B) /Name ,\n/SimpleStyle :\n1 0 Xd\n/Paint ;\n/Def ;\n\ - %AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(A)\n(B)\n%AI9_EndArtStyleList\n", - ops.join("\n") + "%AI9_BeginArtStyles\n/KnownStyle :\n(C) /Name ,\n/SimpleStyle :\n0 R\n0 1 1 0 K\n2 w\n1 0 Xd\n/Paint ;\n/Def ;\n\ + /KnownStyle :\n(A) /Name ,\n/SimpleStyle :\n{}\n{close}/KnownStyle :\n(B) /Name ,\n/SimpleStyle :\n1 0 Xd\n/Paint ;\n/Def ;\n\ + %AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(A)\n(B)\n(C)\n%AI9_EndArtStyleList\n", + ops.iter().map(|(op, _)| op.as_str()).collect::>().join("\n") ); let library = ai::editing_data(200.0, 100.0, &header); survive("one-paint style library", || Some(vectorcraft_eps::layered_ai(library.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let file = ai::ai(library.as_bytes(), ai::page_pdf()); + // How many objects C's look reached, and what applying it answered. + let (mut styled, mut applied) = (0, Value::Null); + survive("one-paint styles applied", || { + let mut s = rich_session(); + let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fuzz.ai"}); + let library = no_panic(s.execute("graphicStyle.loadLibrary", &p)).ok()?["library"].clone(); + no_panic(s.execute("select.all", &json!({}))).ok()?; + // Every style is added and the first one, A when it reads, applied; then C is applied. + let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "apply": true}))); + applied = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["C"], "apply": true}))).ok()?; + let d = (*s.doc().ok()?.doc).clone(); + if let Some(look) = applied["applied"].as_str().and_then(|name| d.graphic_style(name)).map(|g| &g.appearance) { + d.walk(|n| styled += usize::from(n.appearance == *look)); + } + Some(d) + })?; + // Text that isn't a paint operator can leave a string or a comment open over the style list. + if ops.iter().all(|(_, paint)| *paint) { + prop_assert!(applied["applied"].is_string() && styled > 0, "the stroke style wasn't applied: {applied}"); + } } /// Objects on Path records of any shape: ids in any order, repeated or missing, any number of From b1323f8da10f741058a16ef3256e2a49b63c0d0a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 23:07:03 -0800 Subject: [PATCH 13/16] Tests: the one-paint style fuzz case checks C's red 2 pt stroke The fuzz case counted objects that match the look style C applies, so a C read with no paint also passed. It now also checks that C's look has no fill and one red 2 pt stroke. --- crates/engine/tests/import_fuzz.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d6a76a772b..8db9846776 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4247,8 +4247,9 @@ proptest! { let library = ai::editing_data(200.0, 100.0, &header); survive("one-paint style library", || Some(vectorcraft_eps::layered_ai(library.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; let file = ai::ai(library.as_bytes(), ai::page_pdf()); - // How many objects C's look reached, and what applying it answered. - let (mut styled, mut applied) = (0, Value::Null); + // How many objects C's look reached, what applying it answered, and whether that look is + // C's red 2 pt stroke with no fill. + let (mut styled, mut applied, mut red_stroke) = (0, Value::Null, false); survive("one-paint styles applied", || { let mut s = rich_session(); let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fuzz.ai"}); @@ -4259,13 +4260,17 @@ proptest! { applied = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["C"], "apply": true}))).ok()?; let d = (*s.doc().ok()?.doc).clone(); if let Some(look) = applied["applied"].as_str().and_then(|name| d.graphic_style(name)).map(|g| &g.appearance) { + let strokes = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).count(); + red_stroke = look.fill().is_none() + && strokes == 1 + && look.stroke().is_some_and(|s| (s.paint.color(), s.width) == (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0)); d.walk(|n| styled += usize::from(n.appearance == *look)); } Some(d) })?; // Text that isn't a paint operator can leave a string or a comment open over the style list. if ops.iter().all(|(_, paint)| *paint) { - prop_assert!(applied["applied"].is_string() && styled > 0, "the stroke style wasn't applied: {applied}"); + prop_assert!(applied["applied"].is_string() && red_stroke && styled > 0, "the stroke style wasn't applied: {applied}"); } } From 4e0b50bc9c20a19da465407a86621a95d812fff2 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Fri, 13 Nov 2026 08:40:41 +0100 Subject: [PATCH 14/16] i18n(sv): translate 37 new strings, use the agreed terms in 9 Brings the Swedish catalog up to date with upstream main (7d77f8aeb). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/sv.tsv | 55 ++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7fe13c9677..e6f5be8f30 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3537,11 +3537,11 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Inställningar för Tate-chu-yoko… Tate-chu-yoko Settings Inställningar för Tate-chu-yoko - Composite Fonts… Sammansatta teckensnitt… - Composite Fonts Sammansatta teckensnitt - Export Composite Fonts Exportera sammansatta teckensnitt - Import Composite Fonts Importera sammansatta teckensnitt - Delete Font Ta bort teckensnitt + Composite Fonts… Sammansatta typsnitt… + Composite Fonts Sammansatta typsnitt + Export Composite Fonts Exportera sammansatta typsnitt + Import Composite Fonts Importera sammansatta typsnitt + Delete Font Ta bort typsnitt Kanji Kanji Kana Kana Numerals Siffror @@ -5385,7 +5385,7 @@ lighting Light Ljus @msg {_1}: the clipping path is too complex to cut the art with {_1}: urklippsbanan är för komplex för att klippa teckningen med @msg {_1} takes at most {MAX_MEMBERS} objects {_1} tar högst {MAX_MEMBERS} objekt @msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} kan inte bygga solider av teckning som är kapslad mer än {MAX_DEPTH} nivåer djupt -@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Färgen för Rotera runt axel är för komplex för den här ytan; minska Segment eller förenkla övertoningen +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Färgen för Rotera runt axel är för komplex för den här ytan; minska Segment eller förenkla gradienten @msg Revolve edge must be left or right Rotera runt axel: edge måste vara left eller right @msg Revolve cap must be a boolean Rotera runt axel: cap måste vara ett booleskt värde @msg the revolved object has no fill or stroke det roterade objektet har ingen fyllning eller linje @@ -6443,8 +6443,8 @@ axis Y Axis Y-axel @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” begär behörigheterna {asked}: en person måste godkänna dem i skrivbordsappen, eller skicka `grant` i en session som startats med --allow-plugin-grants -@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn insticksprogrammet i skrivbordsappen -@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett insticksprogram +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn pluginet i skrivbordsappen +@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett plugin @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades @msg a heavy filter runs in the background, so it can't declare `commands` ett tungt filter körs i bakgrunden, så det kan inte deklarera `commands` @msg at most {MAX_COMMANDS} `commands` högst {MAX_COMMANDS} `commands` @@ -6549,7 +6549,7 @@ axis Y Axis Y-axel Skill Icons Färdighetsikoner Attack Attack Defend Försvar - Heal Läkning + Heal Retuschering Buff Buff Ult Ult Sword Svärd @@ -8772,3 +8772,40 @@ digits Hindi Hindi Margin Marginal Box Ram Swatch Färgruta + Duplicate Effects Duplicera effekter + Remove Effects Ta bort effekter + Add art with Add as Single Graphic, or drop art here. Lägg till grafik med Lägg till som enkel grafik, eller släpp grafik här. + Add as Single Graphic Lägg till som enkel grafik + Add to Custom Dictionary Lägg till i egen ordlista + Assamese assamesiska + Bulgarian bulgariska + Cancel Opening Avbryt öppning + Change in Custom Dictionary Ändra i egen ordlista + Delete from Custom Dictionary Radera från egen ordlista + Delete SVG Filter Radera SVG-filter + Edit SVG Filter Redigera SVG-filter + Expand as Wireframes Expandera som trådmodeller + Face Yta + Graphics map onto planar faces: pick the Plane or Extrude object type. Grafik mappas på plana ytor: välj objekttypen Plan eller Extrudera. + Import SVG Filter Importera SVG-filter + Import SVG Filter… Importera SVG-filter… + Line Gap Radmellanrum + List Installed Scripts Lista installerade skript + Manage Profiles Hantera profiler + Mapped graphics Mappad grafik + Materials Material + Move Type Path Handle Flytta handtag för textbana + NEW DOCUMENT PROFILES PROFILER FÖR NYTT DOKUMENT + New SVG Filter Nytt SVG-filter + Pick a graphic above to map it onto the object. Välj en grafik ovan för att mappa den på objektet. + Reading the file… Läser filen… + Remove Graphic Ta bort grafik + Replace the selected 3D objects with the edges of their faces, as stroked paths. Ersätt de markerade 3D-objekten med kanterna på deras ytor, som banor med kontur. + Sanskrit sanskrit + Select a 3D object to map the graphic onto it. Markera ett 3D-objekt för att mappa grafiken på det. + Select Symbol Instance Part Markera del av symbolinstans + Spelling Suggestions Stavningsförslag + Stroke Weight Down Minska konturtjocklek + Stroke Weight Up Öka konturtjocklek + The selected objects have different graphics. De markerade objekten har olika grafik. + Word Gap Ordmellanrum From cfa3221dd3d45df3c69e726cddcf08ccebe6b87b Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:21:08 -0500 Subject: [PATCH 15/16] Roadmap M3 row: the #1638 note goes in the Status cell --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 2a414f2ba0..6f631fe88b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -151,7 +151,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 (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; 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), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 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 copies transformed character dimensions (#6404) and 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; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); | Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and 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; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | From 093f04952c74aab54af9cb612ddd6b1c45e32818 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 01:01:59 -0800 Subject: [PATCH 16/16] Image Trace: Create Strokes makes a wide shape's thin features strokes and keeps its body a fill Stroke is the widest feature that can be stroked, and it was applied to each connected shape as a whole: a thick body with thin arms stayed one filled area. Now a shape wider than Stroke somewhere keeps as its area every disc wider than Stroke that fits in it, and each piece outside those discs that reaches out of them at least one and a half times its own width (and 3 px) is traced as a stroked center line. A line's end at the area goes to the cut, so the line's cap closes the join. Corners and edge slivers stay in the area, and a piece that cannot be traced stays in it too. Shapes no wider than Stroke trace as before. Each line keeps one width; without Fills, the area is still a 1 px outline. Docs: docs/target-app-parity.md. --- crates/trace/src/centerline.rs | 154 ++++++++++++++++++++++-- crates/trace/src/centerline/distance.rs | 48 +++++++- crates/trace/src/params.rs | 3 +- crates/trace/src/tests.rs | 60 +++++++++ docs/target-app-parity.md | 1 + 5 files changed, 252 insertions(+), 14 deletions(-) diff --git a/crates/trace/src/centerline.rs b/crates/trace/src/centerline.rs index 332c4499c3..852bdb1858 100644 --- a/crates/trace/src/centerline.rs +++ b/crates/trace/src/centerline.rs @@ -4,7 +4,10 @@ //! 1. **Distance**: an exact Euclidean distance transform in each blob crop gives each pixel //! its distance to the nearest pixel outside the blob. //! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less -//! one) is within the stroke width is a line; wider ones stay filled areas. +//! one) is within the stroke width is a line. A wider one keeps as its area every disc wider +//! than the stroke width that fits in it; each piece outside those discs that reaches out of +//! them at least one and a half times its own width (and 3 px) is a thin feature of it, and a +//! line, whose end at the area goes to the cut, its cap reaching into the area. //! 3. **Skeleton**: Guo–Hall thins the crop to one pixel, preserving diagonal ends, then //! staircase corners that don't connect anything are dropped. //! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels @@ -93,20 +96,111 @@ pub(crate) fn lines( cells_left = left; let Some(dt) = distance::squared(&g.on, g.w, g.h) else { continue }; let widest = 2.0 * f64::from(dt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0; - if widest > max_width { - continue; - } - let Some(line) = trace_line(g, &dt, comp.len(), max_width, img, ink, style) else { continue }; - for &i in &comp { - if let Some(v) = thin.get_mut(i) { - *v = true; + let found = if widest > max_width { + // A wider shape keeps its wide part as an area; its thin features are lines. + arm_lines(&g, &dt, w, max_width, img, ink, style, &mut cells_left) + } else { + trace_line(g, &dt, comp.len(), max_width, img, ink, style, None).map(|line| (comp, line)).into_iter().collect() + }; + for (pixels, line) in found { + for &i in &pixels { + if let Some(v) = thin.get_mut(i) { + *v = true; + } } + out.push(line); } - out.push(line); } (thin, out) } +/// The thin features of component `g` (wider than `max_width` somewhere; `dt` its distances), traced +/// as lines, each with its pixels (indices of the `w`-wide image). A feature that cannot be traced, or +/// past the work left in `cells_left`, stays part of the area. +#[allow(clippy::too_many_arguments)] +fn arm_lines( + g: &Grid, + dt: &[f32], + w: usize, + max_width: f64, + img: &Raster, + ink: [f64; 3], + style: &Style, + cells_left: &mut usize, +) -> Vec<(Vec, Line)> { + let mut out = vec![]; + for arm in arms(g, dt, w, max_width).unwrap_or_default() { + let pixels: Vec = arm.iter().map(|&(i, _)| i).collect(); + let Some(ag) = Grid::from_component(&pixels, w) else { continue }; + let Some(left) = cells_left.checked_sub(ag.w.saturating_mul(ag.h)) else { break }; + *cells_left = left; + let mut cut = vec![false; ag.on.len()]; + for &(i, at_cut) in &arm { + if let Some(c) = ag.index(i, w).and_then(|k| cut.get_mut(k)) { + *c = at_cut; + } + } + let Some(adt) = distance::squared(&ag.on, ag.w, ag.h) else { continue }; + if 2.0 * f64::from(adt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0 > max_width { + continue; + } + if let Some(line) = trace_line(ag, &adt, pixels.len(), max_width, img, ink, style, Some(&cut)) { + out.push((pixels, line)); + } + } + out +} + +/// The thin features of component `g`, which is wider than `max_width` somewhere (`dt` its squared +/// distances to the paper): the pieces of it that lie outside every disc wider than `max_width` +/// that fits in it (the component opened by such a disc) and reach out of those discs at least one +/// and a half times their own width (and 3 px). The rest, with the slivers and corners the discs +/// leave, is the area. Each piece is a list of its pixels (indices of the `w`-wide image), each +/// marked when it touches the area (where the line is cut from it). +fn arms(g: &Grid, dt: &[f32], w: usize, max_width: f64) -> Option>> { + let r = (max_width + 1.0) / 2.0; + let seeds: Vec = dt.iter().map(|&d| f64::from(d) > r * r).collect(); + let near = distance::to_seeds(&seeds, g.w, g.h)?; + drop(seeds); + // (Half a pixel more than the discs' radius, so pixels the discs cut through are area.) + let cover = r + 0.5; + let rest = |i: usize| g.on.get(i) == Some(&true) && near.get(i).is_some_and(|&d| f64::from(d) > cover * cover); + let mut seen = vec![false; g.on.len()]; + let (mut out, mut stack) = (vec![], vec![]); + for start in g.interior() { + if !rest(start) || seen.get(start).copied().unwrap_or(true) { + continue; + } + let mut piece = vec![]; + *seen.get_mut(start)? = true; + stack.push(start); + while let Some(i) = stack.pop() { + let mut at_cut = false; + for j in g.neighbours(i) { + if !rest(j) { + at_cut = true; + } else if !seen.get(j).copied().unwrap_or(true) { + *seen.get_mut(j)? = true; + stack.push(j); + } + } + piece.push((i, at_cut)); + } + let deepest = piece.iter().filter_map(|&(i, _)| dt.get(i)).copied().fold(0.0f32, f32::max); + let width = (2.0 * f64::from(deepest).sqrt() - 1.0).max(1.0); + let farthest = piece.iter().filter_map(|&(i, _)| near.get(i)).copied().fold(0.0f32, f32::max); + if f64::from(farthest).sqrt() - cover < (1.5 * width).max(3.0) { + continue; + } + let image = |i: usize| { + let (x, y) = (usize::try_from(g.x0 + (i % g.w) as i64).ok()?, usize::try_from(g.y0 + (i / g.w) as i64).ok()?); + y.checked_mul(w)?.checked_add(x) + }; + out.push(piece.into_iter().map(|(i, at_cut)| Some((image(i)?, at_cut))).collect::>>()?); + } + Some(out) +} + /// The 8 neighbours of pixel `i` inside the `w` × `h` image. fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator { let (x, y) = ((i % w) as i64, (i / w) as i64); @@ -163,6 +257,23 @@ impl Grid { let comp: Vec<_> = mask.iter().enumerate().filter_map(|(i, &on)| on.then_some(i)).collect(); Self::from_component(&comp, w) } + /// The grid index of pixel `i` of the `image_width`-wide image, when the grid holds it. + fn index(&self, i: usize, image_width: usize) -> Option { + let (x, y) = (i64::try_from(i % image_width.max(1)).ok()? - self.x0, i64::try_from(i / image_width.max(1)).ok()? - self.y0); + let (x, y) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?); + (x < self.w && y < self.h).then(|| y * self.w + x) + } + /// Whether a pixel `cut` marks (`cut` is laid out as the grid) has its center within `radius` of + /// `p` (image coordinates). + fn near_cut(&self, cut: &[bool], p: Point, radius: f64) -> bool { + if !p.x.is_finite() || !p.y.is_finite() || !radius.is_finite() { + return false; + } + let (cx, cy) = (p.x - self.x0 as f64 - 0.5, p.y - self.y0 as f64 - 0.5); + let span = |c: f64, n: usize| ((c - radius).floor().max(0.0) as usize, ((c + radius).ceil().max(0.0) as usize).min(n.saturating_sub(1))); + let ((x0, x1), (y0, y1)) = (span(cx, self.w), span(cy, self.h)); + (y0..=y1).any(|y| (x0..=x1).any(|x| (x as f64 - cx).hypot(y as f64 - cy) <= radius && cut.get(y * self.w + x).copied().unwrap_or(false))) + } fn offsets(&self) -> [isize; 8] { let w = self.w as isize; [-w, -w + 1, 1, w + 1, w, w - 1, -1, -w - 1] @@ -193,8 +304,20 @@ impl Grid { } } -/// The centre lines of one line component (pixel indices of the `w`-wide image). -fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option { +/// The center lines of one line component (pixel indices of the `w`-wide image). `cut`, for a thin +/// feature of a wider shape, marks (in `g`) its pixels that touch the shape's area: an end there is +/// not an end of the ink, and goes to the cut, its cap reaching into the area. +#[allow(clippy::too_many_arguments)] +fn trace_line( + mut g: Grid, + dist: &[f32], + pixels: usize, + max_width: f64, + img: &Raster, + ink: [f64; 3], + style: &Style, + cut: Option<&[bool]>, +) -> Option { thin::thin(&mut g); let mut graph = graph::build(&g, dist)?; graph.simplify(max_width)?; @@ -268,7 +391,9 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra // to a few degrees at best, less without anti-aliasing, and an end already on it is // better left where it is.) let before = inner - *r.pts.get(at(2 * back))?; - let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot(); + // (At a cut from a wider shape, where the skeleton turns along the cut.) + let joined = cut.is_some_and(|cut| g.near_cut(cut, tip, width / 2.0 + 1.5)); + let wide = width >= 3.0 && n > 2 * back && (joined || before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot()); let d = if wide { before } else { tip - inner }; if d.hypot() < 1e-9 { continue; @@ -282,6 +407,11 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra } } let tip = on_line(tip); + if joined { + // The skeleton stops about half a width short of the cut. + *r.pts.get_mut(at(0))? = tip + along * (width / 2.0 + 0.5); + continue; + } let reach = ink.beyond(tip, along); if width >= 3.0 { let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width); diff --git a/crates/trace/src/centerline/distance.rs b/crates/trace/src/centerline/distance.rs index d357c7d681..8e7f595dd4 100644 --- a/crates/trace/src/centerline/distance.rs +++ b/crates/trace/src/centerline/distance.rs @@ -40,7 +40,21 @@ pub(super) fn squared(mask: &[bool], w: usize, h: usize) -> Option> { if mask.len() != size || mask.iter().enumerate().any(|(i, &on)| on && (i < w || i >= size - w || i % w == 0 || i % w + 1 == w)) { return None; } - let mut grid: Vec = mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect(); + two_passes(mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect(), w, h) +} + +/// Squared distances from each pixel of a validated crop to the nearest `seeds` pixel (a very large +/// value everywhere when there is none). +pub(super) fn to_seeds(seeds: &[bool], w: usize, h: usize) -> Option> { + let size = super::crop_size(w, h)?; + if seeds.len() != size { + return None; + } + two_passes(seeds.iter().map(|&seed| if seed { 0.0 } else { FAR }).collect(), w, h) +} + +/// The column pass and then the row pass over `grid` (0 at a seed, `FAR` elsewhere). +fn two_passes(mut grid: Vec, w: usize, h: usize) -> Option> { let n = w.max(h); let (mut f, mut d, mut envelope) = (vec![0.0; n], vec![0.0; n], Vec::with_capacity(n)); for x in 0..w { @@ -97,6 +111,38 @@ mod tests { } } + #[test] + fn distances_to_seeds_equal_brute_force() { + let mut seed = 7u64; + let mut rnd = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 33) as usize + }; + for case in 0..100 { + let (w, h) = (3 + rnd() % 20, 3 + rnd() % 20); + let seeds: Vec = (0..w * h).map(|_| rnd() % 9 == 0).collect(); + let d = to_seeds(&seeds, w, h).unwrap(); + for (i, &actual) in d.iter().enumerate() { + let expected = seeds + .iter() + .enumerate() + .filter(|&(_, &s)| s) + .map(|(j, _)| { + let dx = (i % w).abs_diff(j % w); + let dy = (i / w).abs_diff(j / w); + (dx * dx + dy * dy) as f32 + }) + .min_by(f32::total_cmp); + match expected { + Some(e) => assert_eq!(actual, e, "case {case}, pixel {i}"), + None => assert!(actual > 1e18, "case {case}: no seed, pixel {i} at {actual}"), + } + } + } + assert!(to_seeds(&[true; 8], 3, 3).is_none()); + assert!(to_seeds(&[], 2, 2).is_none()); + } + #[test] fn invalid_crops_are_refused() { assert!(squared(&[], 0, 0).is_none()); diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 6073fa04fe..a6676cd696 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -114,7 +114,8 @@ pub struct TraceParams { pub gradients: bool, /// Create: trace areas as filled shapes. pub fills: bool, - /// Create: trace lines no wider than `stroke_width` as stroked centre lines. + /// Create: trace lines no wider than `stroke_width`, and such thin features of wider shapes, as + /// stroked center lines. pub strokes: bool, /// Stroke: the widest feature (px) traced as a stroke. pub stroke_width: f64, diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index f6176cdd6f..d0a589b916 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -932,6 +932,66 @@ fn wide_areas_stay_filled_and_lines_cross_at_junctions() { assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline"); } +#[test] +fn thin_features_of_a_wide_shape_are_strokes_and_its_body_a_fill() { + // A disc (r = 20) with a 3 px wide arm from it out to x = 140 and a 5 px wide one up to y = 5. + let img = Raster::from_fn(160, 100, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let disc = (px - 50.0).hypot(py - 60.0) <= 20.0; + let across = (py - 60.0).abs() <= 1.5 && (50.0..=140.0).contains(&px); + let up = (px - 50.0).abs() <= 2.5 && (5.0..=60.0).contains(&py); + if disc || across || up { BLACK } else { WHITE } + }); + let res = trace(&img, &strokes(true)); + let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect(); + assert_eq!(fills.len(), 1, "the disc"); + let a = net_area(&fills[0].path); + assert!((a - std::f64::consts::PI * 400.0).abs() < 90.0, "disc area {a}"); + let b = fills[0].path.bounds().unwrap(); + assert!(b.x1 < 76.0 && b.y0 > 34.0, "the fill stays the disc: {b:?}"); + let lines = stroked(&res); + assert_eq!(lines.len(), 2, "{:?}", lines.iter().map(|(p, s)| (p.stroke, s.anchors.len())).collect::>()); + let ends = |s: &vectorcraft_geom::SubPath| (s.anchors[0].p, s.anchors[s.anchors.len() - 1].p); + let (across, up) = if ends(lines[0].1).0.y.min(ends(lines[0].1).1.y) > 50.0 { (lines[0], lines[1]) } else { (lines[1], lines[0]) }; + // The arm across: its free end where the bar ends (less its cap), its other end at the disc, the + // cap closing the join. + let w = across.0.stroke.unwrap(); + assert!((2.0..=4.0).contains(&w), "width {w}"); + let (p, q) = ends(across.1); + let (near, far) = if p.x < q.x { (p, q) } else { (q, p) }; + assert!(far.distance(Point::new(140.0, 60.0)) < 4.0, "free end {far:?}"); + assert!((60.0..=72.5).contains(&near.x) && (near.y - 60.0).abs() < 1.5 && near.x - w / 2.0 <= 70.5, "joined end {near:?}"); + // The arm up, 5 px wide. + let w = up.0.stroke.unwrap(); + assert!((4.0..=6.0).contains(&w), "width {w}"); + let (p, q) = ends(up.1); + let (top, low) = if p.y < q.y { (p, q) } else { (q, p) }; + assert!(top.distance(Point::new(50.0, 5.0)) < 4.0, "free end {top:?}"); + assert!((38.0..=48.0).contains(&low.y) && (low.x - 50.0).abs() < 1.5 && low.y + w / 2.0 >= 40.0 - 0.5, "joined end {low:?}"); + // Strokes only: the disc is outlined and both arms stay strokes. + let res = trace(&img, &strokes(false)); + assert!(res.paths.iter().all(|p| p.stroke.is_some())); + assert_eq!(res.paths.iter().filter(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed).count(), 1, "the disc's outline"); + assert_eq!(stroked(&res).iter().filter(|(p, _)| p.stroke.is_some_and(|w| w > 1.5)).count(), 2, "the two arms"); +} + +#[test] +fn wide_shapes_without_thin_features_stay_one_fill() { + // A square, a diamond and a disc: their corners and edges are not thin features. + let img = Raster::from_fn(240, 90, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let square = (10.0..=70.0).contains(&px) && (15.0..=75.0).contains(&py); + let diamond = (px - 120.0).abs() + (py - 45.0).abs() <= 35.0; + let disc = (px - 200.0).hypot(py - 45.0) <= 30.0; + if square || diamond || disc { BLACK } else { WHITE } + }); + for width in [3.0, 6.0, 12.0] { + let res = trace(&img, &TraceParams { stroke_width: width, ..strokes(true) }); + assert_eq!(res.paths.len(), 3, "width {width}: {:?}", res.paths.iter().map(|p| p.stroke).collect::>()); + assert!(res.paths.iter().all(|p| p.stroke.is_none()), "width {width}"); + } +} + #[test] fn create_settings_parse_with_defaults() { let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..a46db4fa4e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1350,6 +1350,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A blend in a `.ai` or EPS opened from its editing data keeps a Specified Distance spacing with its distance ([Blend Options](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-options-overview.html)) where VectorCraft lays out as many steps between each pair of keys as the file draws, in the same places and colors. A Smooth Color blend opens live only where VectorCraft's own Smooth Color step count equals the drawn steps, and VectorCraft often counts a different number. A blend that VectorCraft doesn't lay out as drawn keeps its drawn steps with a note (`import::ai::tests::a_blend_keeps_its_spacing`, `a_blend_of_three_keys_keeps_its_distance_between_each_pair`, `a_blend_whose_spacing_doesnt_lay_out_its_steps_keeps_them`). - Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged. + - Create Strokes decides stroke or fill per feature, as the [help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html) describes the Strokes width: the widest feature of the image that can become a stroke. A shape wider than Stroke somewhere keeps as its area every disc wider than Stroke that fits in it, and each piece outside those discs that reaches out of them at least one and a half times its own width (and 3 px) is traced as a stroked center line, its end at the area carried to the cut so the line's cap closes the join. Corners and edge slivers stay in the area. Which pieces count as features is VectorCraft's own rule. Each line still has one width; without Fills, the area is a 1 px outline as before. Tests: `thin_features_of_a_wide_shape_are_strokes_and_its_body_a_fill`, `wide_shapes_without_thin_features_stay_one_fill` and `distances_to_seeds_equal_brute_force` in `vectorcraft-trace`. The Image Trace score and estimates are unchanged. Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`.