From 5bf0f337dfa888718173b37b8d3b6d59d6255e3c Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:45:55 +0900 Subject: [PATCH 01/48] #6307: Keep numeric spinner arrows repeating while held --- crates/ui-egui/src/widgets/look_tests.rs | 46 ++++++++++++++++++------ crates/ui-egui/src/widgets/numeric.rs | 23 +++++++----- docs/roadmap.md | 5 +-- docs/target-app-parity.md | 6 ++-- docs/ui-parity.md | 4 ++- 5 files changed, 61 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/widgets/look_tests.rs b/crates/ui-egui/src/widgets/look_tests.rs index 582833127..274861386 100644 --- a/crates/ui-egui/src/widgets/look_tests.rs +++ b/crates/ui-egui/src/widgets/look_tests.rs @@ -429,17 +429,25 @@ fn a_popover_stays_open_for_a_click_on_its_anchor_or_during_a_panel_drag() { } /// #4267: a click on a spinner arrow steps once; an arrow held down steps again and again after a -/// pause, and letting go of it doesn't step once more. +/// pause even past the click timeout (#6307), and letting go doesn't step once more. #[test] fn a_held_spinner_arrow_repeats() { let ctx = themed(Default::default()); let value = std::cell::Cell::new(5.0); + let scroll = std::cell::Cell::new(0.0); + let spinner_rect = std::cell::Cell::new(Rect::NOTHING); let mut add = |ui: &mut Ui| { - ui.horizontal(|ui| { - if let Some(v) = spin_plain(ui, "repeat", value.get(), "", 0, 100.0, 1.0, 0.0, &[]) { - value.set(v); - } + let area = egui::ScrollArea::vertical().max_height(100.0).scroll_source(egui::scroll_area::ScrollSource::DRAG).show(ui, |ui| { + ui.add_space(35.0); + let row = ui.horizontal(|ui| { + if let Some(v) = spin_plain(ui, "repeat", value.get(), "", 0, 100.0, 1.0, 0.0, &[]) { + value.set(v); + } + }); + spinner_rect.set(Rect::from_min_size(row.response.rect.min, vec2(16.0, 26.0))); + ui.add_space(150.0); }); + scroll.set(area.state.clamped_offset().y); }; let shapes = frame(&ctx, 0.0, vec![], &mut add); let spinner = shapes @@ -460,13 +468,31 @@ fn a_held_spinner_arrow_repeats() { frame(&ctx, 1.0, vec![button(true)], &mut add); frame(&ctx, 1.3, vec![], &mut add); assert_eq!(value.get(), 6.0, "no step before the pause"); - for (i, t) in [1.5, 1.6, 1.7].into_iter().enumerate() { + for i in 0..17 { + let t = 1.5 + f64::from(i) * 0.1; frame(&ctx, t, vec![], &mut add); - assert_eq!(value.get(), 7.0 + i as f64, "a step every repeat at {t}"); + assert_eq!(value.get(), 7.0 + f64::from(i), "a step every repeat at {t}"); } - frame(&ctx, 1.75, vec![button(false)], &mut add); - frame(&ctx, 1.8, vec![], &mut add); - assert_eq!(value.get(), 9.0, "letting go doesn't step once more"); + frame(&ctx, 3.2, vec![button(false)], &mut add); + frame(&ctx, 3.3, vec![], &mut add); + assert_eq!(value.get(), 23.0, "letting go doesn't step once more"); + + let down = spinner_rect.get().center_bottom() - vec2(0.0, 6.0); + let modifiers = egui::Modifiers::SHIFT; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + frame(&ctx, 3.9, vec![egui::Event::PointerMoved(down)], &mut add); + frame(&ctx, 4.0, vec![egui::Event::ModifiersChanged(modifiers), button(down, true)], &mut add); + frame(&ctx, 4.5, vec![], &mut add); + assert_eq!(value.get(), 13.0, "Shift+down steps by ten: press {down:?}, current {:?}", spinner_rect.get()); + frame(&ctx, 4.9, vec![], &mut add); + assert_eq!(value.get(), 3.0, "Shift+down repeats past the click timeout"); + + let outside = down - vec2(0.0, 45.0); + frame(&ctx, 5.0, vec![egui::Event::PointerMoved(outside)], &mut add); + frame(&ctx, 5.1, vec![], &mut add); + frame(&ctx, 5.2, vec![button(outside, false)], &mut add); + assert_eq!(value.get(), 3.0, "dragging away and releasing doesn't step"); + assert!(scroll.get() > 0.0, "a drag starting on the spinner can still scroll the panel"); } /// The Kerning field reads `(10+5)` as 15 whether it shows a manual value (`12`) or an automatic diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 04ac7160f..f6eabe0e4 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -855,13 +855,14 @@ fn spin_generic( sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); // A blank field (values that differ) has nothing to step from. A click steps once; an // arrow held down steps again and again after a pause, as a spin button does (#4267). - let repeat = held_repeat(ui, &sresp); - if (repeat == Some(true) || (sresp.clicked() && repeat.is_none())) - && let Some(p) = sresp.interact_pointer_pos() - && let Some(v) = value - { + let is_up = match held_repeat(ui, &sresp) { + Some(true) => Some(ui.rect_contains_pointer(up)), + None if sresp.clicked() => sresp.interact_pointer_pos().map(|p| up.contains(p)), + _ => None, + }; + if let (Some(is_up), Some(v)) = (is_up, value) { let modifiers = ui.input(|i| i.modifiers); - out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops, fmt))); + out = Some(SpinPick::Value(spinner_step(v, step, min, is_up, modifiers, stops, fmt))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; let keyed = if presets.is_empty() { None } else { alt_down_in_field(ui, Rect::from_x_y_ranges(sr.right()..=sr.right() + fw, sr.y_range())) }; @@ -914,9 +915,15 @@ const REPEAT_EVERY: f64 = 0.06; /// must not step once more. fn held_repeat(ui: &Ui, resp: &egui::Response) -> Option { let id = resp.id.with("repeat"); - let now = ui.input(|i| i.time); + let click_dist = ui.ctx().options(|o| o.input_options.max_click_dist); + let (now, primary_held) = ui.input(|i| { + let stayed = i.pointer.total_drag_delta().is_some_and(|d| d.length_sq() <= click_dist * click_dist); + (i.time, i.focused && i.pointer.primary_down() && stayed) + }); let state: Option<(f64, f64, bool)> = ui.data(|d| d.get_temp(id)); - if !resp.is_pointer_button_down_on() { + // Start from an egui press, but keep tracking it past the click timeout without taking drags from a scroll area (#6307). + let down_here = primary_held && resp.contains_pointer() && !resp.long_touched(); + if !resp.enabled() || !down_here || (state.is_none() && !resp.is_pointer_button_down_on()) { ui.data_mut(|d| d.remove::<(f64, f64, bool)>(id)); return state.filter(|(_, _, repeated)| *repeated).map(|_| false); } diff --git a/docs/roadmap.md b/docs/roadmap.md index fb546a446..e927dd8a7 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-09 · **Change:** 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; 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-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); 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) ## Feature submission evidence @@ -153,7 +153,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. | | 2026-11-09 | minor | M4/G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d13cd142e..6d5f4b80e 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-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; 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; `.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-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; 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; `.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) ## Feature submission evidence @@ -387,7 +387,7 @@ what keeps it from beta, not from alpha. | Dimension | % | One agent (h) | Kind | Doc | |---|---:|---:|---|---| | Features (depth, weighted) | 75% | 220–360 | estimated | this file | -| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring) | +| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring); held spinner arrows keep stepping (#6307) | | File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) | | Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | | Localization (the 12 key languages, equally weighted) | 74% | 50–85 | measured from catalogs | [localization-parity.md](localization-parity.md) | @@ -806,6 +806,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. As VectorCraft's own keyboard rule, the locator is one tab stop in the Control bar popover, the Transform panel, the Properties panel and the dialogs, the Arrange Tools dialogs included. While it has the focus it shows an outline, and ←, →, ↑ and ↓ move the reference point one square, stopping at the edges (`the_reference_point_grid_is_one_tab_stop`, `arrow_keys_move_the_reference_point`, `the_reference_point_locator_is_one_tab_stop_and_arrows_move_it`). - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does. + - Numeric spinner arrows keep stepping while held with the mouse past the click timeout and stop on release (#6307; `a_held_spinner_arrow_repeats`). - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. @@ -1901,6 +1902,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. | | 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 72f7502ee..7396ddd41 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-09 · **Change:** 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-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); 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; 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) ## Feature submission evidence @@ -268,6 +268,7 @@ tools, never checked side by side) isn't captured row by row. | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | | Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | +| Numeric spinner arrows step once on a click and keep repeating while held with the mouse | done | #6307; headless test `a_held_spinner_arrow_repeats` holds past egui's click timeout and checks release. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | @@ -494,6 +495,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | From 898db5d72d946de692e4eff39b7ab768db2ac3a5 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:53:11 +0900 Subject: [PATCH 02/48] #6307: Keep spinner evidence separate from shared status lines --- docs/roadmap.md | 4 +++- docs/target-app-parity.md | 4 +++- docs/ui-parity.md | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index e927dd8a7..7c68b4817 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,8 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); 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-09 · **Change:** 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; 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) + +> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). ## Feature submission evidence diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6d5f4b80e..55346ddd8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,8 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; 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; `.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-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; 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; `.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) + +> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). ## Feature submission evidence diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7396ddd41..bc28800fd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,8 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); 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; 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-09 · **Change:** 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) + +> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). ## Feature submission evidence From 804e6d5b14111de3403ad5d57ee068e8956d4b7d Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:57:12 +0900 Subject: [PATCH 03/48] #6307: Keep parity evidence away from the changing header --- docs/target-app-parity.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 55346ddd8..e16c44691 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2,8 +2,6 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; 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; `.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) -> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). - ## Feature submission evidence EPS embedded fonts, 2026-11-09 (#6023–#6067): the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. Evidence: `crates/eps/src/import/tests_composite.rs`, `tests_type42.rs`, `tests_cid.rs`, `tests_type1.rs`; details in [file-format-parity.md](file-format-parity.md). From e56f95270c4de329f447c1fca2868d4937507aed Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:58:06 +0900 Subject: [PATCH 04/48] #6307: Preserve concurrent parity revision entries --- docs/target-app-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e16c44691..ac6a0073b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1904,6 +1904,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s |---|---|---| | 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. | | 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | | 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. | From 9c4a17e3452904cbfeb06e00581b71f0914de16a Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:59:32 +0900 Subject: [PATCH 05/48] #6307: Put spinner revision beside the numeric-field history --- docs/target-app-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ac6a0073b..0bcf43a4d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1902,9 +1902,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| -| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | -| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. | | 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | | 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. | @@ -1913,6 +1911,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-08 | minor | Consolidated the four current Guides feature-area rows using existing global/artboard ruler mode, video ruler and status-bar evidence; remaining gaps, score and estimates unchanged. The earlier detailed missing-items table remains historical. | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | @@ -2267,4 +2266,4 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s 2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. \ No newline at end of file From b07c8080933bb63cba6564ca614d52706c1b0d1a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 16:09:03 -0800 Subject: [PATCH 06/48] Revolve: type, groups and layers revolve, and the profile and face limits are higher Type sweeps as its glyph outlines and stays editable. On a group or layer every visible member sweeps about one axis on the edge of the members' bounds together, each in its own paint. A surface now takes up to 16,384 profile points and 163,840 faces. Curves take fewer samples, down to 4, when a surface passes those limits, and the command reports the failure below that, after the geometry effects before the Revolve and with the cumulative size of its paint copies counted. Mesh export turns the walls of holes toward them. Expand Appearance works on type with Extrude as well as Revolve. Fixes #3617 --- crates/effects/src/revolve.rs | 314 ++++++++++++++++++--- crates/effects/src/solids.rs | 38 ++- crates/engine/src/cmd/effectcmd.rs | 4 +- crates/engine/src/cmd/effectcmd/tests.rs | 122 ++++++++ crates/render/src/fx/bounds.rs | 4 +- crates/three-d/src/lib.rs | 150 ++++++++-- crates/three-d/src/mesh.rs | 7 +- crates/three-d/src/mesh/tests.rs | 43 ++- crates/three-d/src/tests.rs | 1 + crates/three-d/src/tests/caps.rs | 6 +- crates/three-d/src/tests/profile_limits.rs | 124 ++++++++ crates/three-d/src/tests/smooth.rs | 2 +- crates/ui-egui/src/i18n/ca.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hi.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ka.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- docs/mcp.md | 6 +- docs/revolve.md | 9 +- docs/target-app-parity.md | 1 + docs/three-d.md | 4 +- 38 files changed, 762 insertions(+), 117 deletions(-) create mode 100644 crates/three-d/src/tests/profile_limits.rs diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index fd423689f..6dcc46801 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, N use vectorcraft_geom::{PathData, Rect}; use vectorcraft_three_d::{Edge, Revolve}; +use crate::solids::{Profile, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -60,6 +61,7 @@ pub fn revolve_options(p: &Value) -> Revolve { shade: flag(p, "shade", true), smooth_shading: true, cap_ends: flag(p, "cap", true), + curve_samples: None, } } @@ -67,12 +69,61 @@ pub fn revolve_options(p: &Value) -> Revolve { /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; - let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; - let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; - if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { - return Err("Revolve requires at least two anchors".into()); + let effects = &n.appearance.effects; + let before = effects.iter().position(|e| e.visible && e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); + if is_container(n) { + const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors"; + let mut shapes = members(n, "Revolve")?; + if !shapes.iter().any(|s| has_two_anchors(&s.path)) { + return Err(EMPTY.into()); + } + reshape_members(n, before, &mut shapes); + let b = union_bounds(&shapes).ok_or(EMPTY)?; + let options = shared_options(&shapes, revolve_options(p))?; + let mut copies = 0; + return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies)); } - vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) + let (path, _) = object_profile(n, "Revolve")?; + if !has_two_anchors(&path) { + return Err("Revolve requires a shape with at least two anchors".into()); + } + let path = geometry(&path, before, &GeomContext::of(n)); + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + let paints = solid_paints(n); + check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0) +} + +fn has_two_anchors(path: &PathData) -> bool { + path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) +} + +/// The bytes the copies of `paint` take on `faces` faces: a face's fill, its shadow and their seam +/// strokes make up to four copies. +fn paint_copies(faces: usize, paint: &Paint) -> usize { + faces.saturating_mul(4).saturating_mul(paint_bytes(paint)) +} + +/// An error when `path` does not revolve or when its surface in `item`'s paint takes `copies`, the +/// bytes of an object's paint copies so far, past [`MAX_PAINT_COPIES`]. +fn check_surface(path: &PathData, b: Rect, options: Revolve, item: Option<&AppearanceItem>, copies: &mut usize) -> Result<(), String> { + let faces = vectorcraft_three_d::revolve(path, b, options)?; + *copies = copies.saturating_add(item.map_or(0, |i| paint_copies(faces.len(), &paint_of(i).0))); + if *copies > MAX_PAINT_COPIES { + return Err("Revolve paint is too complex for this surface; reduce Segments or simplify the gradient".into()); + } + Ok(()) +} + +/// `options` with one number of samples for every curve of `shapes`, the members of one group: the +/// members share Revolve's limits. +fn shared_options(shapes: &[Profile], options: Revolve) -> Result { + let samples = vectorcraft_three_d::revolve_curve_samples(shapes.iter().map(|s| &s.path), options)?; + Ok(Revolve { curve_samples: Some(samples), ..options }) +} + +/// The item whose paint an object's surface takes: its top fill, else its top stroke. +fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> { + items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last()) } /// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`. @@ -87,38 +138,31 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { Ok(()) } -fn profile(n: &Node) -> Option { - match &n.kind { - NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()), - NodeKind::Compound { children, .. } => { - Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect())) - } - _ => None, - } -} - fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) } +/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts +/// the bytes of the paint copies of every surface of the object. #[allow(clippy::too_many_arguments)] fn surface( n: &Node, path: &PathData, + b: Rect, + options: Revolve, fx: &Effect, paint: Paint, opacity: f32, blend: vectorcraft_doc::color::BlendMode, expand: bool, -) -> Option { - let b = path.bounds()?; + copies: &mut usize, +) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. - let options = revolve_options(&merged_params(REVOLVE, &fx.params)); let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; - // A face's fill, its shadow and their seam strokes: up to four copies of the paint. - if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES { + *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); + if *copies > MAX_PAINT_COPIES { return None; } // Visibility is an expansion option, never a change to live geometry or export baking. @@ -146,6 +190,7 @@ fn surface( }) .collect() }); + let depths: Vec = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect(); let children = faces .into_iter() .map(|face| { @@ -202,7 +247,15 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some(group) + Some((depths, group)) +} + +/// A surface of one object: `shape`'s own, built with options derived from `fx`, about `b`. +fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool) -> Option { + let b = path.bounds()?; + let (paint, opacity, blend) = paint_of(item); + let options = revolve_options(&merged_params(REVOLVE, &fx.params)); + surface(n, path, b, options, fx, paint, opacity, blend, expand, &mut 0).map(|(_, art)| art) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -237,26 +290,23 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { /// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes /// `total` past `limit` stops with an error before the next is built. pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { - let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; + if is_container(n) { + return group_meshes(n, total, limit); + } + let (path, rule) = object_profile(n, "Revolve")?; let ctx = GeomContext::of(n); let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result { - let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let (paint, opacity, _) = paint_of(item); - let mesh = vectorcraft_three_d::revolve_mesh(path, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) }) }; if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { let before = n.appearance.effects.get(..index).unwrap_or_default(); let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let path = geometry(&path, before, &ctx); - let item = n - .appearance - .items - .iter() - .rev() - .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) - .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) - .ok_or("the revolved object has no fill or stroke")?; + let paints = solid_paints(n); + let item = surface_item(&paints).ok_or("the revolved object has no fill or stroke")?; let mut out = vec![]; crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?; return Ok(out); @@ -274,11 +324,37 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } +/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. +fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { + let index = n + .appearance + .effects + .iter() + .position(|e| e.visible && e.id == REVOLVE) + .ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?; + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let mut shapes = members(n, "Revolve")?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; + let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mut out = vec![]; + for s in &shapes { + let Some(item) = surface_item(&s.items) else { continue }; + let (paint, opacity, _) = paint_of(item); + let mesh = vectorcraft_three_d::revolve_mesh(&s.path, s.rule, b, options)?; + crate::solids::keep_mesh(&mut out, total, crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity * s.opacity) }, limit)?; + } + Ok(out) +} + fn art(n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; } - let path = profile(n)?; + if is_container(n) { + return group_art(n, expand); + } + let (path, _) = profile(n)?; let ctx = GeomContext::of(n); let mut children = vec![]; let mut remaining = n.appearance.effects.clone(); @@ -286,16 +362,10 @@ fn art(n: &Node, expand: bool) -> Option { let fx = n.appearance.effects.get(index)?; let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); // An object's surface uses its top visible fill, falling back to its stroke. - let item = n - .appearance - .items - .iter() - .rev() - .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) - .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?; - let (paint, opacity, blend) = paint_of(item); + let paints = solid_paints(n); + let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?)); + children.push(Arc::new(object_surface(n, &path, fx, item, expand)?)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = n .appearance @@ -312,9 +382,8 @@ fn art(n: &Node, expand: bool) -> Option { if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - let (paint, opacity, blend) = paint_of(item); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?; + let mut art = object_surface(n, &p, fx, item, expand)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); children.push(Arc::new(art)); } else { @@ -328,6 +397,52 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } +/// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, +/// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when +/// the effect is only on the group's own fills or strokes or when its members can't be revolved. +fn group_art(n: &Node, expand: bool) -> Option { + let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; + let fx = n.appearance.effects.get(index)?; + let mut shapes = members(n, "").ok()?; + reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); + let b = union_bounds(&shapes)?; + let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + let mut copies = 0; + let mut surfaces = vec![]; + let mut opaque = true; + for s in &shapes { + let Some(item) = surface_item(&s.items) else { continue }; + let (paint, opacity, blend) = paint_of(item); + let opacity = opacity * s.opacity; + opaque &= opacity >= 1.0 && blend == Default::default(); + surfaces.push(surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?); + } + if surfaces.is_empty() { + return None; + } + let plain = surfaces.iter().all(|(depths, s)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + let children = if opaque && plain { + let mut faces = vec![]; + for (depths, s) in surfaces { + if let NodeKind::Group { children, .. } = s.kind { + faces.extend(depths.into_iter().zip(children)); + } + } + // A stable sort: faces at the same depth keep the members' order. + faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] + } else { + surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect() + }; + let remaining = + n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let mut art = n.clone(); + art.kind = NodeKind::Group { children, clip: false }; + art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); + Some(art) +} + #[cfg(test)] mod tests { use super::*; @@ -495,6 +610,117 @@ mod tests { assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]); } + /// A closed rectangle `x` to `x + 10` across, filled solid. + fn member(id: u64, x: f64) -> Arc { + let mut b = BezPath::new(); + b.move_to((x, 0.0)); + b.line_to((x + 10.0, 0.0)); + b.line_to((x + 10.0, 40.0)); + b.line_to((x, 40.0)); + b.close_path(); + Arc::new(Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0))) + } + + fn group_with(params: Value, children: Vec>) -> Node { + let mut g = Node::group(NodeId(9), children); + g.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms).unwrap()); + g + } + + #[test] + fn a_groups_members_share_the_axis_on_the_edge_of_them_all() { + let mut hidden = (*member(3, -500.0)).clone(); + hidden.visible = false; + let nested = Arc::new(Node::group(NodeId(5), vec![member(4, 90.0)])); + let children = vec![member(1, 0.0), nested, Arc::new(hidden)]; + // Left edge, 5 pt out: the axis at -5, the far wall (100) 105 from it. + let g = group_with(json!({"segments":32,"offset":5,"rotationX":0}), children.clone()); + let b = revolve_art(&g).unwrap().visual_bounds().unwrap(); + assert!((b.x0 + 110.0).abs() < 1.5 && (b.x1 - 100.0).abs() < 1.5, "{b:?}"); + // Right edge, 5 pt out: the axis at 105, the far wall (0) 105 from it. + let g = group_with(json!({"segments":32,"offset":5,"edge":"right","rotationX":0}), children); + let b = revolve_art(&g).unwrap().visual_bounds().unwrap(); + assert!((b.x1 - 210.0).abs() < 1.5 && (b.x0 - 0.0).abs() < 1.5, "{b:?}"); + } + + #[test] + fn opaque_members_share_one_back_to_front_order_and_others_keep_their_own_surfaces() { + let g = group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)]); + let art = revolve_art(&g).unwrap(); + assert_eq!(art.children().unwrap().len(), 1, "one order for every face"); + assert!(art.children().unwrap()[0].children().unwrap().len() > 16); + let mut faded = (*member(2, 50.0)).clone(); + faded.opacity = 0.5; + let g = group_with(json!({"segments":16}), vec![member(1, 0.0), Arc::new(faded)]); + let art = revolve_art(&g).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 2, "a transparent member stays a surface of its own"); + assert_eq!(surfaces[1].opacity, 0.5); + } + + fn star(id: u64) -> Arc { + let anchors = (0..1200) + .map(|i| { + let (s, c) = (i as f64 * std::f64::consts::PI / 600.0).sin_cos(); + let r = if i % 2 == 0 { 100.0 } else { 60.0 }; + vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(200.0 + r * c, 200.0 + r * s)) + }) + .collect(); + Arc::new(Node::path( + NodeId(id), + PathData::new(vec![vectorcraft_geom::SubPath::new(anchors, true)]), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + } + + #[test] + fn a_groups_members_share_revolves_limits() { + let alone = group_with(json!({}), vec![star(1)]); + assert!(validate_revolve(&alone, &json!({})).is_ok()); + let both = group_with(json!({}), vec![star(1), star(2)]); + let refused = validate_revolve(&both, &json!({})).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + assert_eq!(revolve_art(&both), None, "past the limits the source draws"); + } + + #[test] + fn validation_measures_the_profile_after_the_geometry_effects_before_revolve() { + let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); + let mut group = group_with(json!({}), vec![star(1)]); + assert!(validate_revolve(&group, &json!({})).is_ok()); + group.appearance.effects.insert(0, roughen.clone()); + assert!(validate_revolve(&group, &json!({})).is_err(), "the group's reshaped member"); + assert_eq!(revolve_art(&group), None); + let mut object = (*star(1)).clone(); + assert!(validate_revolve(&object, &json!({})).is_ok()); + object.appearance.effects.insert(0, roughen); + assert!(validate_revolve(&object, &json!({})).is_err(), "a reshaped object not yet revolved"); + } + + #[test] + fn paint_copies_past_the_budget_are_refused_for_the_whole_group() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let painted = |id: u64, x: f64, stops: usize| { + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut node = (*member(id, x)).clone(); + node.appearance = Appearance::basic(paint, Paint::None, 0.0).into(); + Arc::new(node) + }; + let plain = member(1, 0.0); + let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len(); + let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::(); + let one = group_with(json!({}), vec![painted(1, 0.0, stops)]); + assert_eq!(validate_revolve(&one, &json!({})), Ok(())); + let two = group_with(json!({}), vec![painted(1, 0.0, stops), painted(2, 20.0, stops)]); + let refused = validate_revolve(&two, &json!({})).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + assert_eq!(revolve_art(&two), None, "past the budget the source draws"); + let object = painted(3, 0.0, stops * 2); + assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget"); + } + #[test] fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() { let mut n = profile_node(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3390cc71f..3330cde75 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -138,7 +138,7 @@ pub fn inflate_options(p: &Value) -> Inflate { } /// The shape a solid is made from: a path's or a compound path's outline. -fn profile(n: &Node) -> Option<(PathData, FillRule)> { +pub(crate) fn profile(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)), NodeKind::Compound { children, rule } => { @@ -156,7 +156,7 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> { /// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the /// fill and stroke of its first painted run (its characters' colours). -fn solid_paints(n: &Node) -> Vec { +pub(crate) fn solid_paints(n: &Node) -> Vec { let own: Vec = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) { return own; @@ -207,29 +207,39 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())); } - let (path, rule) = profile(n).ok_or_else(|| { - format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") - })?; + let (path, rule) = object_profile(n, label)?; let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; facets(id, &path, rule, b, p).map(|_| ()) } +/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from +/// (empty, or only spaces) is a shape without anchors, and any other object is the wrong kind. +pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRule), String> { + profile(n).ok_or_else(|| { + if matches!(n.kind, NodeKind::Text(_)) { + format!("{label} requires a shape with at least two anchors") + } else { + format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") + } + }) +} + /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint /// it, and the opacity of the objects it is in (1 for the object with the effect itself). -struct Profile { - path: PathData, - rule: FillRule, - items: Vec, - opacity: f32, +pub(crate) struct Profile { + pub(crate) path: PathData, + pub(crate) rule: FillRule, + pub(crate) items: Vec, + pub(crate) opacity: f32, } /// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? -fn is_container(n: &Node) -> bool { +pub(crate) fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) } /// The bounds of every shape together: the centre a group's members turn about. -fn union_bounds(shapes: &[Profile]) -> Option { +pub(crate) fn union_bounds(shapes: &[Profile]) -> Option { shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c)) } @@ -245,7 +255,7 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000; /// effects and painted with its own fills and strokes. Like the effect on a group in the /// reference app, they turn about one shared axis (the centre of their bounds together) rather /// than each about its own. An error names what the group holds that can't become a solid. -fn members(n: &Node, label: &str) -> Result, String> { +pub(crate) fn members(n: &Node, label: &str) -> Result, String> { fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec) -> Result<(), String> { for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { @@ -385,7 +395,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32 /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. -fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { +pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { if before.iter().any(|e| e.visible && is_geometry(&e.id)) && let Some(b) = union_bounds(shapes) { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 297279b64..028996f61 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -865,8 +865,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // keeps it as its only member). Some((image, false)) => Some(rasterfx::replace_with_image(v, image)), image => { - let m = if container && effects::has_solid(&v) { - // A group's or layer's 3D effect: the shaded faces of its members' solids. + let m = if (container || matches!(n.kind, NodeKind::Text(_))) && (effects::has_solid(&v) || effects::has_revolve(&v)) { + // A 3D effect on a group or layer, or on type: the shaded faces of its solids. effects::expand_leaf(d, &v, &mut stroke_art) } else if container { // A group's or layer's own fills and strokes become art among its members. diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index e3fca957b..eaca5d114 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -193,6 +193,128 @@ fn type_extrudes_as_its_glyphs_and_stays_editable() { assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text"); } +/// #3617: Revolve takes live type and stays type, updating when the text changes, and saves, +/// exports, expands and undoes as one step. +#[test] +fn type_revolves_as_its_glyphs_and_stays_editable() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "O", "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null, "params": {"segments": 16}})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Text(_)), "still type"); + let art = effects::revolve_art(&n).expect("a surface of the glyphs"); + let mut tinted = false; + art.walk(&mut |c| { + if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + tinted |= r > g && r > b; + } + }); + assert!(tinted, "shaded from the characters' red"); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), art, "the same art after a save"); + let meshes = effects::three_d_meshes(&n).unwrap(); + assert_eq!(meshes.len(), 1, "one mesh of every contour of the type"); + s.execute("text.setText", &json!({"ids": [id.0], "text": "OO"})).unwrap(); + assert_ne!(effects::revolve_art(&node(&s, id)).unwrap(), art, "the surface follows the edited text"); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, id)), "expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Text(_))); + assert!(effects::has_revolve(&node(&s, id))); + // Extrude & Bevel on type expands the same way. + s.execute("effect.remove", &json!({"index": 0, "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(!effects::has_solid(&node(&s, id)), "expanded"); +} + +/// Revolve refuses type with no outlines, and a group with no visible shape, without adding an +/// effect. Extrude & Bevel gives the same reason for empty type. +#[test] +fn revolve_refuses_type_and_groups_without_a_shape() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + for text in ["", " "] { + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + for effect in ["threeD.revolve", "threeD.extrude"] { + let err = s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("at least two anchors"), "{effect}, {text:?}: {err}"); + assert!(node(&s, id).appearance.effects.is_empty()); + } + } + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + for member in [a.0, b] { + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + } + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("group holding a shape"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + +/// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps +/// each member's paint, follows an edited member, expands and undoes as one step. +#[test] +fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() { + let (mut s, a) = session_with_rect(); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#cc2200"})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [b], "color": "#0022cc"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null, "params": {"segments": 16, "rotationX": 90}})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().len(), 2, "the members stay as they were"); + let art = effects::revolve_art(&node(&s, g)).expect("a surface of the members"); + // The axis is the left edge of both members together (x = 100), so the right member's far + // wall sweeps out to 200 pt from it on either side: x = 300 and x = -100. + let bounds = art.visual_bounds().unwrap(); + assert!((bounds.x1 - 300.0).abs() < 2.0 && (bounds.x0 + 100.0).abs() < 2.0, "{bounds:?}"); + let (mut red, mut blue) = (false, false); + art.walk(&mut |c| { + if let Some([r, _, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + red |= r > b; + blue |= b > r; + } + }); + assert!(red && blue, "each member keeps its own paint"); + assert_eq!(effects::three_d_meshes(&node(&s, g)).unwrap().len(), 2, "a mesh for each member"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "both members exported as vector faces"); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap(); + assert_ne!(effects::revolve_art(&node(&s, g)).unwrap(), art, "a moved member reshapes the surface"); + s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, g)), "expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(effects::has_revolve(&node(&s, g))); +} + +/// A group holding what can't be swept is refused as a whole, and a clipping group is named. +#[test] +fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() { + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [clip.as_u64().unwrap()], "item": null})).unwrap_err().to_string(); + assert!(err.contains("path, compound path, type or group"), "{err}"); + let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("Revolve on a group") && err.contains("clipping groups"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is /// stored as the angle that shows the same view, and a later angle replaces an earlier strength. #[test] diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 8ad62cf4c..bc4e11c12 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -170,8 +170,8 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { match &n.kind { // A group's or layer's 3D effect: the art of its members' solids. NodeKind::Layer { .. } | NodeKind::Group { clip: false, .. } - if effects::has_solid(n) - && let Some(art) = effects::solid_art(n) => + if (effects::has_solid(n) || effects::has_revolve(n)) + && let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) => { drawn_bounds(symbols, &art) } diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 6ce5f3501..54bc0c5a6 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -18,11 +18,16 @@ mod caps; mod mesh; pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; -use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv}; /// Resource limits shared by desktop, CLI and browser evaluation. -const MAX_PROFILE_POINTS: usize = 1024; +const MAX_PROFILE_POINTS: usize = 16_384; const MAX_FACES: usize = 32768; +/// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art. +const MAX_REVOLVE_FACES: usize = 163_840; +/// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. +const CURVE_SAMPLES: usize = 16; +const MIN_CURVE_SAMPLES: usize = 4; #[derive(Clone, Copy, Debug, Default, PartialEq)] struct V3(f64, f64, f64); @@ -86,6 +91,9 @@ pub struct Revolve { /// Close circular endpoint rings of open profiles. Partial sweeps get sectors; /// the angular cut sides are left open. Enabled by default. pub cap_ends: bool, + /// Samples for each curved profile segment, from [`revolve_curve_samples`] for the surfaces of + /// one object. `None` plans them from the profile alone. + pub curve_samples: Option, } /// The default light's Rotation and Height: the light of the earlier default direction (45° to the @@ -114,6 +122,7 @@ impl Default for Revolve { shade: true, smooth_shading: true, cap_ends: true, + curve_samples: None, } } } @@ -307,6 +316,11 @@ impl Revolve { ..self } } + /// How many steps a sanitized sweep takes. + fn steps(self) -> usize { + ((self.segments as f64 * self.angle / 360.0).ceil() as usize).max(1) + } + fn rotate(self, v: V3) -> V3 { let (sx, cx) = self.rotation_x.to_radians().sin_cos(); let (sy, cy) = self.rotation_y.to_radians().sin_cos(); @@ -449,16 +463,117 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi /// Evaluate a profile in document space. All subpaths share one axis from `bounds`. /// Invalid or excessively complex inputs return an error, never a partial surface. pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { - revolve_faces(path, bounds, options, None) + revolve_faces(path, FillRule::NonZero, bounds, options, None) } -/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn. -fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Option<&mut Vec>) -> Result, String> { +const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex"; +const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile"; + +/// The samples each curved segment takes when `paths`, the profiles of one object's surfaces, are +/// revolved with `options`: [`CURVE_SAMPLES`] when every surface fits the limits together, else as +/// many as fit, the same number for every curve. An error when [`MIN_CURVE_SAMPLES`] do not fit. +/// The count is made before any face is built. +pub fn revolve_curve_samples<'a>(paths: impl IntoIterator, options: Revolve) -> Result { let o = options.sanitized(); + let steps = o.steps(); + let per_edge = if o.shade && o.smooth_shading { steps.saturating_mul(2) } else { steps }; + let (mut anchors, mut contours, mut lines, mut curves, mut caps) = (0usize, 0usize, 0usize, 0usize, 0usize); + for sp in paths.into_iter().flat_map(|p| &p.subpaths) { + anchors = anchors.saturating_add(sp.anchors.len()); + if anchors > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } + contours += 1; + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + for i in 0..segments { + if sp.segment_is_line(i) { + lines += 1; + } else { + curves += 1; + } + } + if o.cap_ends && !sp.closed && segments > 0 { + caps += 2; + } + } + let points = MAX_PROFILE_POINTS.saturating_sub(contours); + if lines.saturating_add(curves.saturating_mul(MIN_CURVE_SAMPLES)) > points { + return Err(TOO_COMPLEX_PROFILE.into()); + } + let edges = (MAX_REVOLVE_FACES.saturating_sub(caps.saturating_mul(steps)) / per_edge).min(points); + if curves == 0 { + return if lines > edges { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(CURVE_SAMPLES) }; + } + let samples = (edges.saturating_sub(lines) / curves).min(CURVE_SAMPLES); + if samples < MIN_CURVE_SAMPLES { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(samples) } +} + +/// Most path elements the search for holes tests a point against, each winding test counting its +/// contour's elements. +const MAX_HOLE_WORK: usize = 16_000_000; + +/// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is +/// outside its closed contour at the segment's middle and not inside. An error past +/// [`MAX_HOLE_WORK`]. +fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { + let contours: Vec<(BezPath, Rect)> = path + .subpaths + .iter() + .map(|sp| { + let contour = PathData::new(vec![sp.clone()]).to_bezpath(); + let bounds = contour.bounding_box(); + (contour, bounds) + }) + .collect(); + let closed = |j: usize| path.subpaths.get(j).is_some_and(|sp| sp.closed); + let mut work = 0usize; + let mut holes = Vec::with_capacity(contours.len()); + for (i, (sp, (own, _))) in path.subpaths.iter().zip(&contours).enumerate() { + if !sp.closed { + holes.push(vec![]); + continue; + } + let direction = if own.area() < 0.0 { -1 } else { 1 }; + let mut segments = Vec::with_capacity(sp.anchors.len()); + for segment in 0..sp.anchors.len() { + let p = sp.segment(segment).eval(0.5); + let mut others = 0i32; + for (_, (contour, _)) in contours.iter().enumerate().filter(|(j, (_, bounds))| *j != i && closed(*j) && bounds.contains(p)) { + work = work.saturating_add(contour.elements().len()); + if work > MAX_HOLE_WORK { + return Err(TOO_COMPLEX_PROFILE.into()); + } + others += contour.winding(p); + } + segments.push(match rule { + FillRule::EvenOdd => others % 2 != 0, + FillRule::NonZero => others != 0 && others + direction == 0, + }); + } + holes.push(segments); + } + Ok(holes) +} + +/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, +/// each turned out of the region `rule` fills. +fn revolve_faces( + path: &PathData, + rule: FillRule, + bounds: Rect, + options: Revolve, + mut mesh: Option<&mut Vec>, +) -> Result, String> { + let o = options.sanitized(); + let curve_samples = match o.curve_samples { + Some(samples) => samples.clamp(1, CURVE_SAMPLES), + None => revolve_curve_samples([path], o)?, + }; + let holes = if mesh.is_some() { hole_segments(path, rule)? } else { vec![] }; // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { mesh.push((vec![face], normal)); - if mesh.len() > MAX_FACES { Err("Revolve surface is too complex; reduce Segments or simplify the profile".to_string()) } else { Ok(()) } + if mesh.len() > MAX_REVOLVE_FACES { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) } }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); @@ -470,7 +585,7 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let center_y = (bounds.y0 + bounds.y1) * 0.5; let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0)); - let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1); + let steps = o.steps(); let sweep = o.angle.to_radians(); let smooth = o.shade && o.smooth_shading; let [lx, ly, lz] = o.light_direction(); @@ -489,32 +604,33 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti if let Some(face) = face { faces.push(face); mesh_cost += cost; - if mesh_cost > MAX_FACES { - return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + if mesh_cost > MAX_REVOLVE_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } } Ok(()) }; let mut count = 0; let mut curve_budget = 0; - for sp in &path.subpaths { + for (contour, sp) in path.subpaths.iter().enumerate() { let mut profile = Vec::new(); let mut tangents = Vec::new(); + let mut hole_edges = Vec::new(); if let Some(first) = sp.anchors.first() { profile.push(first.p); count += 1; if count > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } } let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; - // Bound work before sampling any data. Curves get sixteen samples, straight edges one. + // Bound work before sampling any data. Straight edges get one sample. if segments > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } for i in 0..segments { let curve = sp.segment(i); - let samples = if sp.segment_is_line(i) { 1 } else { 16 }; + let samples = if sp.segment_is_line(i) { 1 } else { curve_samples }; for k in 1..=samples { let t0 = (k - 1) as f64 / samples as f64; let t1 = k as f64 / samples as f64; @@ -537,10 +653,11 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti if v.hypot2() > 1e-20 { v } else { b - a } }; tangents.push([tangent(t0), tangent(t1)]); + hole_edges.push(holes.get(contour).and_then(|h| h.get(i)).copied().unwrap_or(false)); profile.push(b); count += 1; if count > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } } } @@ -565,8 +682,9 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); if area < 0.0 { -1.0 } else { 1.0 } }; - for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() { + for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() { let [a, b] = pair else { continue }; + let turn = if hole { -turn } else { turn }; if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve profile contains invalid coordinates".into()); } diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index 2f2f69844..d1970c202 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -76,10 +76,11 @@ pub fn inflate_meshes( } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than -/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). -pub fn revolve_mesh(path: &PathData, bounds: Rect, options: Revolve) -> Result { +/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). The +/// walls of holes under `rule`, such as the counter of an O, face into the hole. +pub fn revolve_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Revolve) -> Result { let mut faces = vec![]; - crate::revolve_faces(path, bounds, options, Some(&mut faces))?; + crate::revolve_faces(path, rule, bounds, options, Some(&mut faces))?; mesh(&faces) } diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 3251cbb3e..27706f0b5 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -231,7 +231,7 @@ fn a_revolved_profile_is_a_closed_cylinder() { let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); assert!(closed(&m)); let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0; assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); @@ -246,7 +246,7 @@ fn a_partial_revolve_caps_its_ends_with_sectors() { let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0); assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); } @@ -261,7 +261,7 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles"); assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length"); assert!(closed(&m), "{from} to {to}"); @@ -269,6 +269,43 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct } } +/// The radial direction of every triangle's normal at the wall `x` pt from the axis, each as the +/// sign of the normal's part along the way from the axis: 1 away from it, -1 toward it. +fn wall_directions(m: &Mesh, x: f64) -> Vec { + m.triangles + .iter() + .filter_map(|t| { + let [a, b, c] = corners(m, *t); + let centre = [(a[0] + b[0] + c[0]) / 3.0, 0.0, (a[2] + b[2] + c[2]) / 3.0]; + let radius = centre[0].hypot(centre[2]); + let n = m.normals[t[0] as usize]; + ((radius - x).abs() < 1.0 && n[1].abs() < 0.5).then(|| dot(n, centre).signum()) + }) + .collect() +} + +#[test] +fn the_wall_of_a_counter_faces_into_the_hole() { + // A square ring 20 pt thick, its axis at the outer square's left edge minus 10: the outer wall + // reaches 110 pt from the axis, and the hole's far wall 90 pt from it. + let ring = |hole_clockwise: bool, rule: FillRule| { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 20.0, 20.0, 60.0, hole_clockwise); + let (p, r) = shape(&b); + revolve_mesh(&p, rule, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap() + }; + for (clockwise, rule) in [(true, FillRule::NonZero), (true, FillRule::EvenOdd), (false, FillRule::EvenOdd)] { + let m = ring(clockwise, rule); + assert!(wall_directions(&m, 110.0).iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}"); + let hole = wall_directions(&m, 90.0); + assert!(!hole.is_empty() && hole.iter().all(|s| *s < 0.0), "the hole's far wall faces the axis, {clockwise} {rule:?}"); + } + // Wound the same way under the nonzero rule, the inner square is not a hole. + let m = ring(false, FillRule::NonZero); + assert!(wall_directions(&m, 90.0).iter().all(|s| *s > 0.0)); +} + #[test] fn inflate_puffs_the_front_toward_the_viewer() { let mut b = BezPath::new(); diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index e2b6985fb..1b0ffa3ff 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -4,6 +4,7 @@ mod caps; mod classic; mod lighting; mod multiple_lights; +mod profile_limits; mod smooth; #[test] diff --git a/crates/three-d/src/tests/caps.rs b/crates/three-d/src/tests/caps.rs index 32fc5ac0b..cae43f4a5 100644 --- a/crates/three-d/src/tests/caps.rs +++ b/crates/three-d/src/tests/caps.rs @@ -93,14 +93,14 @@ fn caps_share_surface_limits_and_malformed_inputs_stay_bounded() { assert!(faces.len() <= 384); let mut excessive = BezPath::new(); excessive.move_to((40.0, 0.0)); - for i in 1..=1024 { + for i in 1..=MAX_PROFILE_POINTS { excessive.line_to((40.0, i as f64)); } - assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, 1024.0), options()).is_err()); + assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, MAX_PROFILE_POINTS as f64), options()).is_err()); assert!(revolve(&PathData::default(), bounds, options()).unwrap().is_empty()); // Viewed along the axis, the side walls disappear but the caps still consume // the same face budget. Repeated rings cannot bypass it through degenerate walls. - let repeated = PathData::new(vec![path.subpaths[0].clone(); 129]); + let repeated = PathData::new(vec![path.subpaths[0].clone(); 1025]); let o = Revolve { segments: 128, ..options() }; assert!(revolve(&repeated, bounds, Revolve { cap_ends: false, ..o }).unwrap().is_empty()); assert!(revolve(&repeated, bounds, o).unwrap_err().contains("surface is too complex")); diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs new file mode 100644 index 000000000..025f2d9df --- /dev/null +++ b/crates/three-d/src/tests/profile_limits.rs @@ -0,0 +1,124 @@ +use crate::*; +use vectorcraft_geom::{Anchor, SubPath}; + +/// A closed star of `points` tips: twice as many straight edges. +fn star(points: usize) -> PathData { + let anchors = (0..points * 2) + .map(|i| { + let (s, c) = (i as f64 * std::f64::consts::PI / points as f64).sin_cos(); + let r = if i % 2 == 0 { 100.0 } else { 60.0 }; + Anchor::corner(Point::new(200.0 + r * c, 200.0 + r * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// An open wave of `anchors` smooth anchors: one curved segment between each two. +fn wave(anchors: usize) -> PathData { + let anchors = (0..anchors) + .map(|i| { + let p = Point::new(100.0 + if i % 2 == 0 { 0.0 } else { 20.0 }, i as f64); + Anchor::smooth(p, p + Vec2::new(6.0, 0.3)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, false)]) +} + +fn revolved(path: &PathData, options: Revolve) -> Result, String> { + revolve(path, path.bounds().unwrap(), options) +} + +#[test] +fn a_star_of_600_points_revolves_at_the_default_segments() { + let faces = revolved(&star(600), Revolve::default()).unwrap(); + // The tip on the axis gives two edges with a side of no length. + assert!((1198 * 64 * 2..=1200 * 64 * 2).contains(&faces.len()), "{}", faces.len()); + let refused = revolved(&star(600), Revolve { segments: 128, ..Revolve::default() }).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); +} + +#[test] +fn a_curved_profile_past_the_old_limit_takes_fewer_samples_for_each_curve() { + let wave = wave(200); + assert!(revolved(&wave, Revolve::default()).is_ok()); + let samples = revolve_curve_samples([&wave], Revolve::default()).unwrap(); + assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}"); +} + +#[test] +fn a_profile_within_the_limits_keeps_every_sample() { + assert_eq!(revolve_curve_samples([&wave(50)], Revolve::default()), Ok(CURVE_SAMPLES)); + assert_eq!(revolve_curve_samples([&star(5)], Revolve::default()), Ok(CURVE_SAMPLES)); +} + +#[test] +fn two_thousand_curves_fit_at_low_segments_and_are_refused_before_any_face_at_high() { + let wave = wave(2001); + let samples = revolve_curve_samples([&wave], Revolve { segments: 8, ..Revolve::default() }).unwrap(); + assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}"); + assert!(revolved(&wave, Revolve { segments: 8, ..Revolve::default() }).is_ok()); + let refused = revolve_curve_samples([&wave], Revolve { segments: 128, ..Revolve::default() }).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + assert_eq!(revolved(&wave, Revolve { segments: 128, ..Revolve::default() }), Err(refused)); +} + +#[test] +fn too_many_anchors_are_refused_as_a_profile() { + let refused = revolve_curve_samples([&wave(MAX_PROFILE_POINTS + 1)], Revolve { segments: 8, ..Revolve::default() }).unwrap_err(); + assert_eq!(refused, TOO_COMPLEX_PROFILE); +} + +#[test] +fn a_profile_at_the_point_limit_is_taken_and_one_past_it_is_refused() { + let straight = |anchors: usize| { + let anchors = (0..anchors).map(|i| Anchor::corner(Point::new(100.0 + (i % 2) as f64, i as f64))).collect(); + PathData::new(vec![SubPath::new(anchors, false)]) + }; + let narrow = Revolve { angle: 1.0, cap_ends: false, segments: 8, ..Revolve::default() }; + assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS)], narrow), Ok(CURVE_SAMPLES)); + assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS + 1)], narrow), Err(TOO_COMPLEX_PROFILE.to_string())); +} + +#[test] +fn the_profiles_of_one_object_share_one_budget() { + let (one, other) = (star(600), star(600)); + let o = Revolve::default(); + assert!(revolve_curve_samples([&one], o).is_ok()); + assert!(revolve_curve_samples([&other], o).is_ok()); + let refused = revolve_curve_samples([&one, &other], o).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + // Contours of one profile count together as well. + let both = PathData::new(one.subpaths.iter().chain(&other.subpaths).cloned().collect()); + assert_eq!(revolve_curve_samples([&both], o), Err(refused)); +} + +#[test] +fn deeply_nested_contours_stop_the_search_for_holes() { + // Concentric triangles: each tests every contour outside it. + let triangle = |r: f64| { + let anchors = [(0.0, -r), (r, r), (-r, r)].iter().map(|(x, y)| Anchor::corner(Point::new(200_000.0 + x, 200_000.0 + y))).collect(); + SubPath::new(anchors, true) + }; + let nested = PathData::new((1..=4000).map(|i| triangle(i as f64)).collect()); + assert_eq!(hole_segments(&nested, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string())); + let few = PathData::new((1..=4).map(|i| triangle(i as f64)).collect()); + assert_eq!(hole_segments(&few, FillRule::EvenOdd).map(|h| h.len()), Ok(4)); +} + +#[test] +fn overlapping_contours_are_holes_only_where_they_overlap() { + let square = |x: f64, y: f64, side: f64| { + let corners = [(x, y), (x + side, y), (x + side, y + side), (x, y + side)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }; + let overlapping = PathData::new(vec![square(0.0, 0.0, 100.0), square(40.0, 40.0, 100.0)]); + let expected = vec![vec![false, true, true, false], vec![true, false, false, true]]; + assert_eq!(hole_segments(&overlapping, FillRule::EvenOdd), Ok(expected)); +} + +#[test] +fn given_samples_are_used_for_each_curve() { + let wave = wave(4); + let at = |samples| revolved(&wave, Revolve { curve_samples: Some(samples), cap_ends: false, segments: 8, ..Revolve::default() }).unwrap().len(); + assert_eq!(at(4) * 2, at(8)); +} diff --git a/crates/three-d/src/tests/smooth.rs b/crates/three-d/src/tests/smooth.rs index c65172326..085255ecb 100644 --- a/crates/three-d/src/tests/smooth.rs +++ b/crates/three-d/src/tests/smooth.rs @@ -118,6 +118,6 @@ fn unshaded_legacy_degenerate_and_high_complexity_surfaces_stay_bounded() { } assert!(revolve(&p, b, Revolve { smooth_shading: true, angle: 0.0, ..o }).unwrap().is_empty()); let mut p = path("M40 0"); - p.subpaths[0].anchors.extend((1..400).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); + p.subpaths[0].anchors.extend((1..1100).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); assert!(revolve(&p, p.bounds().unwrap(), Revolve { smooth_shading: true, segments: 128, offset: 30.0, ..o }).is_err()); } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f..8bf4166ef 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5912,7 +5912,7 @@ digits Malayalam Malaiàlam @msg {key} must be a finite coordinate {key} ha de ser una coordenada finita @msg {key} must be an array of non-negative integer object ids {key} ha de ser una matriu d’identificadors d’objecte enters no negatius @msg {key} must be Unix seconds (an integer) or null {key} ha de ser segons Unix (un enter) o null -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} actualment requereix un traçat o un traçat compost. Converteix primer els textos, les imatges i els grups en traçats. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} actualment requereix un traçat, un traçat compost, text o un grup. Converteix primer les imatges i altres objectes en traçats. @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} conserva 1 dels {n} taulers del document: el fitxer es reobre amb un. Activa Conserva les capacitats d’edició per conservar-los tots. @msg {label} produced an empty result {label} ha produït un resultat buit @msg {label} requires a shape with at least two anchors {label} requereix una forma amb com a mínim dos punts d’ancoratge diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2..1d4c776a8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5587,7 +5587,7 @@ mark-style Japanese Japonské Bevel both sides Zkosení na obou stranách Presets Předvolby @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objekt může mít jen jeden 3D efekt. Upravte volby stávajícího efektu, nebo jej nejprve odstraňte. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} zatím vyžaduje cestu nebo složenou cestu. Nejprve převeďte text, obrazy a skupiny na cesty. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} zatím vyžaduje cestu, složenou cestu, text nebo skupinu. Obrazy a další objekty nejprve převeďte na cesty. @msg {label} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body @msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý @msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238..0ba5a6f4e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5154,7 +5154,7 @@ lighting Light Licht Bevel both sides Abgeflachte Kante beidseitig Presets Vorgaben @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ein Objekt kann nur einen 3D-Effekt haben. Bearbeiten Sie die Optionen des vorhandenen Effekts oder entfernen Sie ihn zuerst. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} erfordert derzeit einen Pfad oder zusammengesetzten Pfad. Wandeln Sie Text, Bilder und Gruppen zuerst in Pfade um. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} erfordert derzeit einen Pfad, zusammengesetzten Pfad, Text oder eine Gruppe. Wandeln Sie Bilder und andere Objekte zuerst in Pfade um. @msg {label} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten @msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex @msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6e..7fa049316 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4948,7 +4948,7 @@ digits Malayalam Μαλαγιάλαμ Bevel Inside Εσωτερική λοξότμηση Bevel both sides Λοξότμηση και των δύο πλευρών @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ένα αντικείμενο δέχεται ένα εφέ 3D. Επεξεργαστείτε τις επιλογές του υπάρχοντος εφέ ή αφαιρέστε το πρώτα. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. Το {label} απαιτεί προς το παρόν διαδρομή ή σύνθετη διαδρομή. Μετατρέψτε πρώτα το κείμενο, τις εικόνες και τις ομάδες σε διαδρομές. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. Το {label} απαιτεί προς το παρόν διαδρομή, σύνθετη διαδρομή, κείμενο ή ομάδα. Μετατρέψτε πρώτα τις εικόνες και τα άλλα αντικείμενα σε διαδρομές. @msg {label} requires a shape with at least two anchors το {label} απαιτεί σχήμα με τουλάχιστον δύο σημεία αγκύρωσης @msg The shape is too complex for a 3D effect Το σχήμα είναι πολύ σύνθετο για εφέ 3D @msg The 3D surface is too complex; simplify the shape Η επιφάνεια 3D είναι πολύ σύνθετη· απλοποιήστε το σχήμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d5059411..ae983373a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5528,7 +5528,7 @@ mark-style Japanese Japonesas Bevel both sides Bisel en ambos lados Presets Ajustes preestablecidos @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objeto admite un solo efecto 3D. Edite las opciones del efecto existente o quítelo primero. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} requiere por ahora un trazado o un trazado compuesto. Convierta primero el texto, las imágenes y los grupos en trazados. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} requiere por ahora un trazado, un trazado compuesto, texto o un grupo. Convierta primero las imágenes y otros objetos en trazados. @msg {label} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla @msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111..682a667ad 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5471,7 +5471,7 @@ mark-style Japanese Japanilainen Bevel both sides Viiste molemmin puolin Presets Esiasetukset @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objektilla voi olla yksi 3D-tehoste. Muokkaa olemassa olevan tehosteen asetuksia tai poista se ensin. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} vaatii tällä hetkellä polun tai yhdistelmäpolun. Muunna teksti, kuvat ja ryhmät ensin poluiksi. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} vaatii tällä hetkellä polun, yhdistelmäpolun, tekstin tai ryhmän. Muunna kuvat ja muut objektit ensin poluiksi. @msg {label} requires a shape with at least two anchors {label} vaatii muodon, jossa on vähintään kaksi ankkuria @msg The shape is too complex for a 3D effect Muoto on liian monimutkainen 3D-tehosteelle @msg The 3D surface is too complex; simplify the shape 3D-pinta on liian monimutkainen; yksinkertaista muotoa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e..a2d7f6081 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5158,7 +5158,7 @@ lighting Light Lumière Bevel both sides Biseau des deux côtés Presets Paramètres prédéfinis @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objet n'accepte qu'un seul effet 3D. Modifiez les options de l'effet existant ou supprimez-le d'abord. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} nécessite pour l'instant un tracé ou un tracé transparent. Convertissez d'abord le texte, les images et les groupes en tracés. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} nécessite pour l'instant un tracé, un tracé transparent, du texte ou un groupe. Convertissez d'abord les images et les autres objets en tracés. @msg {label} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage @msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D @msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03dec..90b483dc0 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5400,7 +5400,7 @@ antiAlias Sharp शार्प @msg {key} must name a registered panel {key} एक पंजीकृत पैनल का नाम होना चाहिए @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} फ़ाइलों में पारदर्शिता नहीं होती और इस फ़ाइल में {what} होगा: पहले इसे फ़्लैट करें (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) {kind} इमेज ओपन नहीं हो सकतीं (document.formats देखें) -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} वर्तमान में एक पाथ या यौगिक पाथ की आवश्यकता होती है। पहले पाथ के लिए प्रकार, इमेज और समूहों को परिवर्तित करें। +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप या समूह आवश्यक है। पहले इमेज और अन्य ऑब्जेक्ट को पाथ में परिवर्तित करें। @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37..58537a6c2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5570,7 +5570,7 @@ imageTrace Outline Körvonal Bevel both sides Élletörés mindkét oldalon Presets Készletek @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Egy objektumnak csak egy 3D effektusa lehet. Szerkessze a meglévő effektus beállításait, vagy előbb távolítsa el. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} egyelőre görbét vagy összetett görbét igényel. Előbb alakítsa görbévé a szöveget, a képeket és a csoportokat. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} egyelőre görbét, összetett görbét, szöveget vagy csoportot igényel. Előbb alakítsa görbévé a képeket és az egyéb objektumokat. @msg {label} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel @msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz @msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272..6e843ce15 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5196,7 +5196,7 @@ lighting Light Luce Bevel both sides Smusso su entrambi i lati Presets Predefiniti @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un oggetto può avere un solo effetto 3D. Modificate le opzioni dell'effetto esistente oppure rimuovetelo prima. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} richiede per ora un tracciato o un tracciato composto. Convertite prima testo, immagini e gruppi in tracciati. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} richiede per ora un tracciato, un tracciato composto, testo o un gruppo. Convertite prima immagini e altri oggetti in tracciati. @msg {label} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio @msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0..8e2335c24 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5238,7 +5238,7 @@ lighting Light ライト Bevel both sides 両面にベベル Presets プリセット @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 1 つのオブジェクトに適用できる 3D 効果は 1 つです。既存の効果のオプションを編集するか、先に削除してください。 -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} には現在、パスまたは複合パスが必要です。テキスト、画像、グループは先にパスに変換してください。 +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} には現在、パス、複合パス、テキスト、またはグループが必要です。画像やその他のオブジェクトは先にパスに変換してください。 @msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です @msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません @msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf5..915bf1831 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4959,7 +4959,7 @@ font-technology Variable ცვლადი Bevel both sides წაკვეთა ორივე მხარეს Presets პრესეტები @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. ობიექტს ერთი 3D ეფექტი შეიძლება ჰქონდეს. შეცვალეთ არსებული ეფექტის პარამეტრები ან ჯერ წაშალეთ ის. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: ამჟამად საჭიროა ტრაექტორია ან შედგენილი ტრაექტორია. ჯერ გარდაქმენით ტექსტი, გამოსახულებები და ჯგუფები ტრაექტორიებად. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი ან ჯგუფი. ჯერ გარდაქმენით გამოსახულებები და სხვა ობიექტები ტრაექტორიებად. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e8316..6dbb47203 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5157,7 +5157,7 @@ lighting Light 광원 Bevel both sides 양면 베벨 Presets 사전 설정 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 오브젝트에는 3D 효과를 하나만 적용할 수 있습니다. 기존 효과의 옵션을 편집하거나 먼저 제거하십시오. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}에는 현재 패스 또는 컴파운드 패스가 필요합니다. 문자, 이미지 및 그룹을 먼저 패스로 변환하십시오. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}에는 현재 패스, 컴파운드 패스, 문자 또는 그룹이 필요합니다. 이미지 및 기타 개체를 먼저 패스로 변환하십시오. @msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다 @msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다 @msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679..6356fb8b3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5417,7 +5417,7 @@ mark-style Japanese Japońskie Bevel both sides Faza po obu stronach Presets Ustawienia @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Obiekt może mieć jeden efekt 3D. Edytuj opcje istniejącego efektu lub najpierw go usuń. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} wymaga obecnie ścieżki lub ścieżki złożonej. Najpierw przekonwertuj tekst, obrazy i grupy na ścieżki. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} wymaga obecnie ścieżki, ścieżki złożonej, tekstu lub grupy. Najpierw przekonwertuj obrazy i inne obiekty na ścieżki. @msg {label} requires a shape with at least two anchors {label} wymaga kształtu z co najmniej dwoma punktami @msg The shape is too complex for a 3D effect Kształt jest zbyt złożony dla efektu 3D @msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68c..7e30236ac 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5527,7 +5527,7 @@ mark-style Japanese Japonesas Bevel both sides Chanfro nos Dois Lados Presets Predefinidos @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Um objeto aceita apenas um efeito 3D. Edite as opções do efeito existente ou remova-o primeiro. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} exige, por enquanto, um caminho ou caminho composto. Converta antes texto, imagens e grupos em caminhos. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} exige, por enquanto, um caminho, caminho composto, texto ou grupo. Converta antes imagens e outros objetos em caminhos. @msg {label} requires a shape with at least two anchors {label} exige uma forma com pelo menos dois pontos de ancoragem @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e83..67cece3c2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5190,7 +5190,7 @@ lighting Light Свет Bevel both sides Фаска с обеих сторон Presets Наборы @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. У объекта может быть только один 3D-эффект. Измените параметры существующего эффекта или сначала удалите его. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: пока требуется контур или составной контур. Сначала преобразуйте текст, изображения и группы в контуры. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: пока требуется контур, составной контур, текст или группа. Сначала преобразуйте изображения и другие объекты в контуры. @msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками @msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта @msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d..fab8dd2b1 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5062,7 +5062,7 @@ lighting Light Ljus Bevel both sides Fasning på båda sidor Presets Förinställningar @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ett objekt kan bara ha en 3D-effekt. Redigera alternativen för den befintliga effekten eller ta bort den först. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} kräver för närvarande en bana eller sammansatt bana. Konvertera text, bilder och grupper till banor först. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} kräver för närvarande en bana, sammansatt bana, text eller grupp. Konvertera bilder och andra objekt till banor först. @msg {label} requires a shape with at least two anchors {label} kräver en form med minst två ankarpunkter @msg The shape is too complex for a 3D effect Formen är för komplex för en 3D-effekt @msg The 3D surface is too complex; simplify the shape 3D-ytan är för komplex; förenkla formen diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006c..6719a2b01 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4858,7 +4858,7 @@ lighting Light Işık Bevel both sides Her iki tarafa da eğim verin Presets Ön ayarlar @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Bir nesne bir 3D efekti alır. Mevcut efektin seçeneklerini düzenleyin veya önce onu kaldırın. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} şu anda bir yol veya bileşik yol gerektiriyor. Önce türü, görüntüleri ve grupları yollara dönüştürün. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} şu anda bir yol, bileşik yol, yazı veya grup gerektiriyor. Önce görüntüleri ve diğer nesneleri yollara dönüştürün. @msg {label} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir @msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık @msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a5..efc018cb0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5176,7 +5176,7 @@ lighting Light Світло Bevel both sides Фаска з обох боків Presets Набори @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Об'єкт може мати лише один 3D-ефект. Змініть параметри наявного ефекту або спершу вилучіть його. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: поки що потрібен контур або складений контур. Спершу перетворіть текст, зображення та групи на контури. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: поки що потрібен контур, складений контур, текст або група. Спершу перетворіть зображення та інші об'єкти на контури. @msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками @msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту @msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003..875757c25 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5171,7 +5171,7 @@ theme Appearance Giao diện @msg {C}: no effect at index {from} {C}: không có hiệu ứng tại chỉ mục {from} @msg {depth}-bit PSD files in this colour mode can't be read tệp PSD {depth}-bit ở chế độ màu này không thể đọc được @msg {key} must be a finite coordinate {key} phải là tọa độ hữu hạn -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} hiện yêu cầu một đường dẫn hoặc đường dẫn phức. Hãy chuyển văn bản, hình ảnh và nhóm thành đường dẫn trước. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} hiện yêu cầu một đường dẫn, đường dẫn phức, văn bản hoặc nhóm. Hãy chuyển hình ảnh và các đối tượng khác thành đường dẫn trước. @msg {label} produced an empty result {label} tạo ra kết quả trống @msg {label} requires a shape with at least two anchors {label} yêu cầu một hình dạng có ít nhất hai điểm neo @msg {label}: lost track of a shape's style {label}: mất dấu kiểu của hình dạng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d..3d5937c83 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5457,7 +5457,7 @@ lighting Light 光源 Bevel both sides 双面斜角 Presets 预设 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一个对象只能应用一种 3D 效果。请编辑现有效果的选项,或先将其移去。 -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}目前需要路径或复合路径。请先将文字、图像和编组转换为路径。 +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路径、复合路径、文字或编组。请先将图像和其他对象转换为路径。 @msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状 @msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b..1221ceaed 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5283,7 +5283,7 @@ lighting Light 光源 Bevel both sides 雙面斜角 Presets 預設集 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一個物件只能套用一種 3D 效果。請編輯現有效果的選項,或先將其移除。 -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}目前需要路徑或複合路徑。請先將文字、影像和群組轉換為路徑。 +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路徑、複合路徑、文字或群組。請先將影像和其他物件轉換為路徑。 @msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀 @msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀 diff --git a/docs/mcp.md b/docs/mcp.md index 81dd91344..7ab4e07de 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2347,10 +2347,12 @@ default) white ones knock out instead; `"discard"` writes none. ## Revolve (initial 3D effect) -`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path -or compound path. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and +`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path, +compound path, type, or a group or layer of them. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. +Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` diff --git a/docs/revolve.md b/docs/revolve.md index 2ea788f90..836c3faa3 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -2,7 +2,10 @@ This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). -Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**. +Select a path, compound path, type, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. +Type stays editable and sweeps as its glyph outlines. On a group or layer, every visible member sweeps about one axis +on the chosen edge of the members' bounds together, in its own paint, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) +describes for a group. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. @@ -88,13 +91,13 @@ with the standard command. - Lighting offers Standard, Diffuse, Top Left and Right, using the [shared original rigs](three-d.md#presets). Other effective settings show Custom; changing the view does not change the recognized lighting preset. -- Paths and compound paths only. Type, images and groups must be converted to paths first. +- Paths, compound paths, type, and groups or layers of them only. Images and other objects must be converted to paths first. - Open profiles have circular end caps by default; turning Cap off keeps them open. Partial-sweep angular cut sides remain open. Closed profile boundaries are swept as provided. - One Revolve per source object. Edit its options rather than applying another. - Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. - Smooth diffuse shading, one to ten directional lights, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights. - Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility. -- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. Smooth shaded side quads become two triangles and share that same limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. +- Curves use sixteen samples per segment, and as few as four when a surface would otherwise pass its limits. Surfaces are limited to 16,384 profile points and 163,840 faces, the members of a group together. A straight segment takes one point, a curve takes at least four, and each contour takes one. Smooth shaded side quads become two triangles and share the face limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. - Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. - Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c37d5234c..9fe8d2c21 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1040,6 +1040,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). + - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). - Pathfinder Options ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the panel menu's Pathfinder Options… sets Precision (0.001–10 pt: results refit to within it, thinner pieces dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (`object.pathfinder.options`, kept in the preferences) for every Shape Mode and Pathfinder operation; the defaults keep the earlier output (`pathops` `precision_drops_pieces_thinner_than_it`, engine `tests_pathops` Pathfinder Options tests, `panels::pathfinder` dialog test). diff --git a/docs/three-d.md b/docs/three-d.md index f3088cc8d..0e9901061 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -232,8 +232,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. on top can still lie a few points behind another nearby piece. - Extrude & Bevel and Rotate retain planar shading. Colored Inflate uses smooth vector lighting; white-light Inflate keeps its original polygon shading; bounded cell triangulation leaves a cell flat when it contains a complete hole or an unusually complex/degenerate contour. Its geometric silhouette is still a polygon approximation. - Paths, compound paths, type and groups or layers of them (see [Groups](#groups)); images, symbol instances, - clipping groups and other live objects have to be converted first. Revolve on a group is not built yet. One 3D - effect per object. + clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about + one axis ([revolve.md](revolve.md)). One 3D effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, From dd914586ffacf2b805ac6fc76c25f0f9911bea02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:03:20 -0800 Subject: [PATCH 07/48] Guides: Lock Guides by Default in Preferences locks guides when the app starts Preferences > Guides & Grid gets a Lock Guides by Default checkbox (lockGuidesByDefault), off by default. With it on, the app starts with View > Guides > Lock Guides on. Changing the checkbox sets the lock at once; an OK that leaves it unchanged keeps the session's lock, and View > Guides > Lock Guides still toggles the lock for the session without changing the preference. Reset Preferences turns it off and unlocks guides when it was on. Headless sessions start unlocked unless prefs.set turns it on, and older preference files read it as off. Lock Guides works as before: new guides can still be dragged out of a ruler or added with guide.add while guides are locked. The lock code moves from view.guides.lock into Session::set_guides_locked so the preference and the menu share it. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/docmenu.rs | 39 +++++---- crates/engine/src/cmd/prefscmds.rs | 7 ++ crates/engine/src/prefs.rs | 3 + crates/engine/src/tests/menucmds.rs | 34 ++++++++ crates/engine/src/tests/prefs.rs | 104 +++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/prefs_dialog/tests.rs | 48 +++++++++++ docs/mcp.md | 5 ++ docs/target-app-parity.md | 2 +- 35 files changed, 252 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index a475bc743..5f8508f4e 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -183,7 +183,7 @@ pub fn specs() -> Vec { "Lock Guides", ["View", "Guides"], Some("Cmd+Alt+;"), - "{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}", + "{locked?: bool} toggle (or set) the session's guide lock, leaving the lockGuidesByDefault preference as it is; locking deselects the ruler guides and guide paths in every open document; guide.add and a guide dragged out of a ruler still make new guides while locked → {locked}", has_doc, lock_guides ), @@ -277,6 +277,27 @@ impl Session { pub fn guides_locked(&self) -> bool { self.menu.guides_locked } + + /// Set View → Guides → Lock Guides. Locked guides can't stay selected, ruler guides or guide + /// paths, in any open document ([`Session::select`] lets those go in the active one). + pub(crate) fn set_guides_locked(&mut self, locked: bool) { + self.menu.guides_locked = locked; + if !locked { + return; + } + let guides_selected = + self.doc().is_ok_and(|st| !st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))); + // It fails only without a document, and then no guide is selected. + if guides_selected { + let _ = self.select(|_, sel| sel.guides.clear()); + } + let active = self.active; + for (i, st) in self.docs.iter_mut().enumerate() { + if Some(i) != active && crate::deselect_hidden(&self.menu, &st.doc, &mut st.selection) { + st.revision += 1; + } + } + } } // ---------- Select ---------- @@ -717,21 +738,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { fn lock_guides(s: &mut Session, p: &Value) -> Result { let v = switch_or(p, "locked", !s.menu.guides_locked, "view.guides.lock")?; - s.menu.guides_locked = v; - // Locked guides can't stay selected, ruler guides or guide paths, in any open document - // ([`Session::select`] lets those go in the active one). - let st = s.doc()?; - if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) { - s.select(|_, sel| sel.guides.clear())?; - } - if v { - let active = s.active; - for (i, st) in s.docs.iter_mut().enumerate() { - if Some(i) != active && crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) { - st.revision += 1; - } - } - } + s.set_guides_locked(v); Ok(json!({ "locked": v })) } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f3cf6416f..e8f6f5770 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -227,6 +227,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // Guides & Grid p!("guideColor", "Guides & Grid", "Guides", "Color", color), p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)), + p!("lockGuidesByDefault", "Guides & Grid", "Guides", "Lock Guides by Default", bool), p!("gridColor", "Guides & Grid", "Grid", "Color", color), p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)), p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", len(1.0, 16383.0, General)), @@ -690,10 +691,16 @@ impl Session { let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder; let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions; let corner_radius_changed = p.corner_radius != self.prefs.corner_radius; + let lock_guides_changed = p.lock_guides_by_default != self.prefs.lock_guides_by_default; if p.trace_presets != self.prefs.trace_presets { self.trace_preset_changes.replaced(); } self.prefs = p; + // Guides & Grid › Lock Guides by Default sets the lock when it changes, the saved value at + // start included. Otherwise the lock stays as View › Guides › Lock Guides left it. + if lock_guides_changed { + self.set_guides_locked(self.prefs.lock_guides_by_default); + } if corner_radius_changed { // General › Corner Radius is the Rounded Rectangle tool's radius (#394): a tool already // made (active, or lent a selection tool for a drag) takes the new value. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d3b034526..5c21a6aa5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -114,6 +114,8 @@ pub struct Prefs { // Guides & Grid pub guide_color: String, pub guide_style: String, + /// Lock Guides by Default: the app starts with View › Guides › Lock Guides on. + pub lock_guides_by_default: bool, pub grid_color: String, pub grid_style: String, pub gridline_every: f64, @@ -421,6 +423,7 @@ impl Default for Prefs { identify_objects_by: s("objectName"), guide_color: s("#4affff"), guide_style: s("lines"), + lock_guides_by_default: false, grid_color: s("#c8c8c8"), grid_style: s("lines"), gridline_every: 72.0, diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 1bc75a82a..e59f76457 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3687,6 +3687,40 @@ fn ruler_guides_and_lock() { assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); } +/// While guides are locked, a guide dragged out of a ruler and `guide.add` still make new guides, +/// each in one undo step and not selected. The ruler guides and guide paths in the document, the +/// new guides included, can't be selected, moved or deleted. +#[test] +fn new_guides_can_be_made_while_guides_are_locked() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let path = guide_path(&mut s, 300.0); + s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + let n = undo_len(&s); + s.ruler_guide(true, &PointerEvent::new(PointerKind::Drag, 153.0, 30.0), true, v).unwrap(); + s.ruler_guide(true, &PointerEvent::new(PointerKind::Up, 153.0, 30.0), true, v).unwrap(); + assert_eq!(guides(&s), [(false, 50.0), (true, 153.0)]); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "New Guide"); + assert_eq!(s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap()["index"], 2); + assert_eq!(guides(&s).len(), 3); + assert_eq!(selected_guides(&s), Vec::::new()); + for (id, params) in [ + ("guide.select", json!({"indexes": [1]})), + ("guide.move", json!({"index": 1, "pos": 170})), + ("guide.remove", json!({"index": 1})), + ("guide.setLayer", json!({"index": 1, "layer": s.doc().unwrap().current_layer().unwrap().0})), + ] { + assert!(matches!(s.execute(id, ¶ms), Err(EngineError::Disabled(..))), "{id}"); + } + assert_eq!(guides(&s).len(), 3); + sel(&mut s, &[path]); + assert!(selected(&s).is_empty(), "a guide path can't be selected"); +} + #[test] fn invalid_guide_lock_parameters_keep_guides_selected() { let mut s = session(); diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 52755f2cb..8e3c043e4 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -199,6 +199,110 @@ fn reset_category_and_all() { assert!(s.execute("prefs.reset", &json!({"category": "Bogus"})).is_err()); } +/// A rectangle made into a guide path; nothing is selected after. +fn guide_path(s: &mut Session, x: f64) -> NodeId { + let id = square(s, x, 0.0); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + id +} + +/// Lock Guides by Default is off, and preferences saved before it existed leave guides unlocked. +#[test] +fn lock_guides_by_default_is_off_and_older_preferences_leave_guides_unlocked() { + assert!(!Prefs::default().lock_guides_by_default); + let older: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap(); + assert!(!older.lock_guides_by_default); + let mut s = Session::new(); + s.apply_prefs(older); + assert!(!s.guides_locked()); + assert!(s.execute("prefs.set", &json!({"key": "lockGuidesByDefault", "value": "yes"})).is_err()); + assert!(!s.guides_locked() && !s.prefs.lock_guides_by_default); +} + +/// Preferences saved with the option on start a session with guides locked. A document opened in +/// it has its ruler guides and guide paths locked, and new guides can still be made. View › Guides +/// › Lock Guides then unlocks them for the session and leaves the preference on. +#[test] +fn saved_lock_guides_by_default_locks_a_new_session_and_the_documents_it_opens() { + let file = std::env::temp_dir().join(format!("vc-lock-guides-{}.vectorcraft", std::process::id())); + let file = file.to_string_lossy().to_string(); + let mut unlocked = new_doc(); + assert!(!unlocked.guides_locked(), "a session without the preference"); + let path = guide_path(&mut unlocked, 100.0); + unlocked.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap(); + unlocked.execute("document.save", &json!({"path": file})).unwrap(); + + // The preference is set with no document open, as a headless session can. + let mut first = Session::new(); + first.execute("prefs.set", &json!({"key": "lockGuidesByDefault", "value": true})).unwrap(); + let saved = first.prefs.to_json(); + assert_eq!(saved["lockGuidesByDefault"], json!(true)); + + let mut s = Session::new(); + s.apply_prefs(serde_json::from_value(saved).unwrap()); + assert!(s.guides_locked()); + s.execute("document.open", &json!({"path": file})).unwrap(); + let _ = std::fs::remove_file(&file); + assert!(s.guides_locked(), "opening a document keeps the lock"); + assert!(matches!(s.execute("guide.select", &json!({"indexes": [0]})), Err(EngineError::Disabled(..)))); + s.execute("select.set", &json!({"ids": [path.0]})).unwrap(); + assert!(s.doc().unwrap().selection.objects.is_empty(), "a guide path can't be selected"); + assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 20})).unwrap()["index"], 1); + + assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap(), json!({"locked": false})); + assert!(s.prefs.lock_guides_by_default, "the menu leaves the preference as it is"); + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("select.set", &json!({"ids": [path.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [path]); +} + +/// Changing the preference sets the lock at once and deselects guides in every open document. +/// A change to any other preference, or setting it to the value it has, keeps the session's lock. +#[test] +fn changing_lock_guides_by_default_sets_the_lock_and_other_changes_keep_it() { + let mut s = new_doc(); + s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let path = guide_path(&mut s, 100.0); + s.execute("select.set", &json!({"ids": [path.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + + set_pref(&mut s, "lockGuidesByDefault", json!(true)); + assert!(s.guides_locked()); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the guide path in the active document"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert!(s.doc().unwrap().selection.guides.is_empty(), "the ruler guide in the other document"); + + s.execute("view.guides.lock", &json!({"locked": false})).unwrap(); + set_pref(&mut s, "keyboardIncrement", json!(4)); + set_pref(&mut s, "lockGuidesByDefault", json!(true)); + assert!(!s.guides_locked(), "the session's unlock stays"); + + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + set_pref(&mut s, "lockGuidesByDefault", json!(false)); + assert!(!s.guides_locked(), "turning the preference off unlocks guides"); +} + +/// Reset turns the option off and unlocks guides. With the option already off, a reset keeps a +/// lock set from the View menu. +#[test] +fn resetting_lock_guides_by_default_unlocks_guides_only_when_it_was_on() { + let mut s = new_doc(); + for params in [json!({"category": "Guides & Grid"}), json!({})] { + set_pref(&mut s, "lockGuidesByDefault", json!(true)); + assert!(s.guides_locked()); + s.execute("prefs.reset", ¶ms).unwrap(); + assert!(!s.prefs.lock_guides_by_default, "{params}"); + assert!(!s.guides_locked(), "{params}"); + } + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + s.execute("prefs.reset", &json!({})).unwrap(); + assert!(s.guides_locked()); +} + /// `prefs.set` refuses a recovery or plug-ins folder that is missing or is a file. Nothing changes, /// other values in the same call included, and the document gets no undo step. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b7006..8290243b4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1826,6 +1826,7 @@ font-technology Variable Променливи Lock (⌘2) Заключване (⌘2) Lock Grid Заключване на мрежата Lock Guides Заключване на водачите + Lock Guides by Default Заключване на водачите по подразбиране Lock Others Заключване на другите Lock Slices Заключване на фрагментите Lock Station Point Заключване на базовата точка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a655..a053e2d85 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -600,6 +600,7 @@ Hide Guides Sakrij vodilice Show Guides Prikaži vodilice Lock Guides Zaključaj vodilice + Lock Guides by Default Zaključaj vodilice po zadanom Unlock Guides Otključaj vodilice Make Guides Napravi vodilice Release Guides Oslobodi vodilice diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f..ed8904f2e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2339,6 +2339,7 @@ digits Malayalam Malaiàlam Lock Artboards Bloca els taulers Lock Grid Bloca la quadrícula Lock Guides Bloca les guies + Lock Guides by Default Bloca les guies per defecte Lock Others Bloca la resta Lock Selected Bloca la selecció Lock Slices Bloca els talls diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2..5ff23db0b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -609,6 +609,7 @@ Hide Guides Skrýt vodítka Show Guides Zobrazit vodítka Lock Guides Zamknout vodítka + Lock Guides by Default Ve výchozím stavu zamknout vodítka Unlock Guides Odemknout vodítka Make Guides Vytvořit vodítka Release Guides Uvolnit vodítka diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718b..fd76bd327 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -559,6 +559,7 @@ Hide Guides Skjul hjælpelinjer Show Guides Vis hjælpelinjer Lock Guides Lås hjælpelinjer + Lock Guides by Default Lås hjælpelinjer som standard Unlock Guides Lås hjælpelinjer op Make Guides Opret hjælpelinjer Release Guides Frigør hjælpelinjer diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238..2733a424c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1893,6 +1893,7 @@ file-filter Templates Vorlagen Lock (⌘2) Sperren (⌘2) Lock Grid Raster sperren Lock Guides Hilfslinien sperren + Lock Guides by Default Hilfslinien standardmäßig sperren Lock Others Andere sperren Lock Slices Slices sperren Lock Station Point Standpunkt sperren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6e..6332db247 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1725,6 +1725,7 @@ font-technology Variable Μεταβλητές Location: Θέση: Lock Κλείδωμα Lock Guides Κλείδωμα οδηγών + Lock Guides by Default Κλείδωμα οδηγών από προεπιλογή Lock Slices Κλείδωμα τομών Low Χαμηλή Luminosity Φωτεινότητα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d5059411..e310599bd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1896,6 +1896,7 @@ font-technology Variable Variables Lock (⌘2) Bloquear (⌘2) Lock Grid Bloquear cuadrícula Lock Guides Bloquear guías + Lock Guides by Default Bloquear guías de forma predeterminada Lock Others Bloquear otros Lock Slices Bloquear sectores Lock Station Point Bloquear punto de estación diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111..6a3e24f75 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -600,6 +600,7 @@ Hide Guides Piilota apuviivat Show Guides Näytä apuviivat Lock Guides Lukitse apuviivat + Lock Guides by Default Lukitse apuviivat oletuksena Unlock Guides Avaa apuviivojen lukitus Make Guides Luo apuviivat Release Guides Vapauta apuviivat diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e..092a473f8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1890,6 +1890,7 @@ font-technology Variable Variables Lock (⌘2) Verrouiller (⌘2) Lock Grid Verrouiller la grille Lock Guides Verrouiller les repères + Lock Guides by Default Verrouiller les repères par défaut Lock Others Verrouiller les autres Lock Slices Verrouiller les tranches Lock Station Point Verrouiller le point d’observation diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03dec..8aae344e2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2555,6 +2555,7 @@ antiAlias Sharp शार्प Lock Artboards लॉक आर्टबोर्ड Lock Grid लॉक ग्रिड Lock Guides गाइड्स लॉक करें + Lock Guides by Default गाइड्स डिफ़ॉल्ट रूप से लॉक करें Lock Others लॉक अन्य Lock Selected लॉक सिलेक्टेड Lock Slices स्लाइस लॉक करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37..7c48b0fed 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2377,6 +2377,7 @@ file-filter Templates Sablonok Lock (⌘2) Zárolás (⌘2) Lock Grid Rács zárolása Lock Guides Segédvonalak zárolása + Lock Guides by Default Segédvonalak zárolása alapértelmezetten Lock Others Többi zárolása Lock Slices Szeletek zárolása Lock Station Point Nézőpont zárolása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f..165314cac 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -559,6 +559,7 @@ Hide Guides Sembunyikan panduan Show Guides Tampilkan panduan Lock Guides Kunci panduan + Lock Guides by Default Kunci panduan secara default Unlock Guides Buka kunci panduan Make Guides Buat panduan Release Guides Lepaskan panduan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272..fea1d4bab 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1925,6 +1925,7 @@ file-filter Templates Modelli Lock (⌘2) Blocca (⌘2) Lock Grid Blocca griglia Lock Guides Blocca guide + Lock Guides by Default Blocca guide per impostazione predefinita Lock Others Blocca altri Lock Slices Blocca sezioni Lock Station Point Blocca punto di osservazione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0..49f59d70e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -627,6 +627,7 @@ digits Malayalam マラヤーラム数字 Guides ガイド Hide Guides ガイドを隠す Lock Guides ガイドをロック + Lock Guides by Default ガイドを初期設定でロック Make Guides ガイドを作成 Release Guides ガイドを解除 Clear Guides ガイドを消去 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf5..4a2baf29a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -521,6 +521,7 @@ Hide Guides დამხმარე ხაზების დამალვა Show Guides დამხმარე ხაზების ჩვენება Lock Guides დამხმარე ხაზების ჩაკეტვა + Lock Guides by Default დამხმარე ხაზების ნაგულისხმევად ჩაკეტვა Unlock Guides დამხმარე ხაზების განბლოკვა Make Guides დამხმარე ხაზების შექმნა Release Guides დამხმარე ხაზების მოხსნა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e8316..370359218 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -622,6 +622,7 @@ digits Malayalam 말라얄람어 Guides 안내선 Hide Guides 안내선 숨기기 Lock Guides 안내선 잠그기 + Lock Guides by Default 기본적으로 안내선 잠그기 Make Guides 안내선 만들기 Release Guides 안내선 해제 Clear Guides 안내선 지우기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3..98e8f812c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -573,6 +573,7 @@ Hide Guides Hulplijnen verbergen Show Guides Hulplijnen tonen Lock Guides Hulplijnen vergrendelen + Lock Guides by Default Hulplijnen standaard vergrendelen Unlock Guides Hulplijnen ontgrendelen Make Guides Hulplijnen maken Release Guides Hulplijnen vrijgeven diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679..aeb5f1212 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -566,6 +566,7 @@ Hide Guides Ukryj linie pomocnicze Show Guides Pokaż linie pomocnicze Lock Guides Zablokuj linie pomocnicze + Lock Guides by Default Domyślnie blokuj linie pomocnicze Unlock Guides Odblokuj linie pomocnicze Make Guides Utwórz linie pomocnicze Release Guides Uwolnij linie pomocnicze diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68c..95b722dfe 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1617,6 +1617,7 @@ antiAlias Sharp Nítido Lock (⌘2) Bloquear (⌘2) Lock Grid Bloquear Grade Lock Guides Bloquear Guias + Lock Guides by Default Bloquear Guias por Padrão Lock Slices Bloquear Fatias Lock Station Point Bloquear Ponto de Estação Lossless (lossy WebP isn't available yet) Sem Perdas (WebP com perdas ainda não está disponível) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e83..0a657eadc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1922,6 +1922,7 @@ file-filter Templates Шаблоны Lock (⌘2) Заблокировать (⌘2) Lock Grid Заблокировать сетку Lock Guides Заблокировать направляющие + Lock Guides by Default Блокировать направляющие по умолчанию Lock Others Заблокировать остальные Lock Slices Заблокировать фрагменты Lock Station Point Заблокировать точку стояния diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d..1662ac431 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1837,6 +1837,7 @@ font-technology Variable Variabla Lock (⌘2) Lås (⌘2) Lock Grid Lås rutnät Lock Guides Lås stödlinjer + Lock Guides by Default Lås stödlinjer som standard Lock Others Lås övriga Lock Slices Lås skivor Lock Station Point Lås betraktarpunkt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006c..6d1af1aee 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1730,6 +1730,7 @@ font-technology Variable Değişken Lock (⌘2) Kilitle (⌘2) Lock Grid Izgarayı Kilitle Lock Guides Kilit Kılavuzları + Lock Guides by Default Kılavuzları Varsayılan Olarak Kilitle Lock Others Diğerlerini Kilitle Lock Slices Dilimleri Kilitle Lock Station Point Kilit İstasyon Noktası diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a5..0c2006390 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1919,6 +1919,7 @@ file-filter Templates Шаблони Lock (⌘2) Заблокувати (⌘2) Lock Grid Заблокувати сітку Lock Guides Заблокувати напрямні + Lock Guides by Default Блокувати напрямні за замовчуванням Lock Others Заблокувати решту Lock Slices Заблокувати фрагменти Lock Station Point Заблокувати точку стояння diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003..62b1ef58e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2203,6 +2203,7 @@ digits Hindi Hindi Lock (⌘2) Khóa (⌘2) Lock Grid Khóa lưới Lock Guides Khóa đường căn + Lock Guides by Default Khóa đường căn theo mặc định Lock Others Khóa các mục khác Lock Slices Khóa lát cắt Lock Station Point Khóa điểm trạm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d..b74d62818 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1806,6 +1806,7 @@ file-filter Templates 模板 Lock (⌘2) 锁定 (⌘2) Lock Grid 锁定网格 Lock Guides 锁定参考线 + Lock Guides by Default 默认锁定参考线 Lock Slices 锁定切片 Lock Station Point 锁定基准点 Lossless (lossy WebP isn't available yet) 无失真 (尚未提供有失真的 WebP) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b..b413d7886 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2800,6 +2800,7 @@ file-filter Templates 範本 Lock (⌘2) 鎖定 (⌘2) Lock Grid 鎖定格點 Lock Guides 鎖定參考線 + Lock Guides by Default 預設鎖定參考線 Lock Others 鎖定其他 Lock Slices 鎖定切片 Lock Station Point 鎖定基準點 diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 9ef8f3ed7..93b84f488 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -225,6 +225,54 @@ fn prefs_persist_through_ui_state() { assert_eq!(b.session.prefs.gridline_every, 50.0); } +/// Guides & Grid › Lock Guides by Default: shown in its category, applied by OK, saved with the +/// UI state and read back as locked guides. A file without the key leaves guides unlocked. +#[test] +fn lock_guides_by_default_shows_applies_and_restores() { + let mut a = app(); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + open(&mut a, Some("Guides & Grid")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Lock Guides by Default"), "{text}"); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields["lockGuidesByDefault"], json!(false)); + a.ui.dialog.as_mut().unwrap().fields.insert("lockGuidesByDefault".into(), json!(true)); + confirm(&mut a).unwrap(); + assert!(a.session.guides_locked()); + assert_eq!(crate::menus::dynamic_label(&a, "view.guides.lock", ""), "Unlock Guides"); + assert_eq!(a.ui.engine_prefs["lockGuidesByDefault"], json!(true)); + + let saved = serde_json::to_vec(&a.ui).unwrap(); + let mut b = app(); + assert!(!b.session.guides_locked()); + b.ui = serde_json::from_slice::(&saved).unwrap().sanitized(); + restore(&mut b); + assert!(b.session.guides_locked()); + + let mut older = app(); + older.ui.engine_prefs = json!({"gridlineEvery": 50}); + restore(&mut older); + assert!(!older.session.guides_locked()); +} + +/// OK with the option unchanged keeps the lock from the View menu. OK after turning the option +/// off, as Reset Preferences does in the dialog, unlocks guides. +#[test] +fn preferences_ok_changes_the_guide_lock_only_with_the_option() { + let mut a = app(); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + a.run("view.guides.lock", json!({"locked": true})).unwrap(); + open(&mut a, Some("Guides & Grid")); + confirm(&mut a).unwrap(); + assert!(a.session.guides_locked(), "the option did not change"); + a.run("view.guides.lock", json!({"locked": false})).unwrap(); + a.run("prefs.set", json!({"key": "lockGuidesByDefault", "value": true})).unwrap(); + assert!(a.session.guides_locked()); + open(&mut a, Some("Guides & Grid")); + a.ui.dialog.as_mut().unwrap().fields.insert("lockGuidesByDefault".into(), Prefs::default().to_json()["lockGuidesByDefault"].clone()); + confirm(&mut a).unwrap(); + assert!(!a.session.guides_locked(), "the option went off"); +} + #[test] fn old_pref_files_keep_their_brightness() { let mut a = app(); diff --git a/docs/mcp.md b/docs/mcp.md index 81dd91344..ed42e6f90 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1786,6 +1786,11 @@ View › Guides › Lock Guides (`view.guides.lock`) deselects them and the path document, and refuses these commands until unlocked. While it is on, a command that selects art, such as `edit.undo`, `edit.redo`, `object.exitIsolation`, `object.unlockAll`, `object.showAll` or `edit.paste`, leaves the guides out. `view.guides.lock` rejects a `locked` value that is not a boolean. Omitting `locked` toggles the current value. +While guides are locked, `guide.add` and a guide dragged out of a ruler still make new guides, which are locked too. +Preferences › Guides & Grid › Lock Guides by Default (`lockGuidesByDefault`, off by default) locks guides when the +desktop app starts. Changing the preference, with `prefs.set` or `prefs.reset`, sets the lock to the new value at once. +`view.guides.lock` changes the lock for the session and does not change the preference. A headless session starts +with guides unlocked, as the preference is off until `prefs.set` turns it on. `view.guides.release {index?, ids?}` (View › Guides › Release Guides) turns guides into paths → `{count}`. With `index` it releases that ruler guide, and with `ids` it releases those guide paths (and the guide paths inside them). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c37d5234c..e18978476 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -969,7 +969,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle in degrees as its value: a click or a drag in it points the Angle at the pointer and gives it the keyboard focus, Tab reaches it after the Roundness field, and the arrow keys step the Angle by 1° (10° with Shift, Home and End to −180° and 180°). The Blob Brush Tool Options also have Fidelity, which the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list as setting how far the pointer moves before the path gets a new anchor point. The range and default are VectorCraft's own values: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`). A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`). -- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. +- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. - **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (layers dropped in the empty space under the last row go below the bottom layer, #2260; dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). The Filter button at the right of Search All keeps the rows that are of an object kind ticked under By Object (paths, shapes, text, images, effects, groups, linked files, guides) and in a state ticked under By Layer (locked, hidden), with the rows that hold them; a section with nothing ticked takes every row. The filter combines with the search, the button is lit while a filter is on, and Clear All in its popup turns the filter off (`ui.layersFilter`). The filter lasts the session. From bb142cc063fca72704709b95b336613a7e9ca372 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:19:13 -0800 Subject: [PATCH 08/48] Revolve: the fills and strokes of one object share the paint-copy budget Each fill or stroke with its own Revolve counted the copies of its paint from zero, so an object with several of them could draw past the 256 MiB budget. They now share one count, and past it the source path draws, as for the members of a group. effect.apply and effect.setParams on a fill or stroke now check the Revolve in that item's paint, after the item's geometry effects before it, together with the object's other items that carry a Revolve. Before, they checked the object's top fill. docs/revolve.md marks the axis position, type handling and member paint as VectorCraft's own rules, and docs/mcp.md says how the budget is counted. --- crates/effects/src/lib.rs | 2 +- crates/effects/src/revolve.rs | 93 ++++++++++++++++++++++-- crates/effects/src/solids.rs | 11 +++ crates/engine/src/cmd/effectcmd.rs | 10 ++- crates/engine/src/cmd/effectcmd/tests.rs | 25 +++++++ crates/three-d/src/mesh/tests.rs | 6 +- docs/mcp.md | 2 +- docs/revolve.md | 6 +- 8 files changed, 140 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e5b3201a4..82db95b5b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,7 +87,7 @@ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_r pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as, - validate_solid, validate_three_d, validate_three_d_params, view_options, + validate_solid, validate_three_d, validate_three_d_at, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 6dcc46801..cf275fc2d 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -93,6 +93,38 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0) } +/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of +/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve, +/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level +/// Revolve draws in place of those surfaces. +pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> { + if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() { + return validate_revolve(n, p); + } + validate_revolve_params(p)?; + const EMPTY: &str = "Revolve requires a shape with at least two anchors"; + let (path, _) = object_profile(n, "Revolve")?; + if !has_two_anchors(&path) { + return Err(EMPTY.into()); + } + let ctx = GeomContext::of(n); + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut copies = 0; + for (i, item) in n.appearance.items.iter().enumerate() { + let effects = item.effects(); + let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) }; + let options = match at.and_then(|at| effects.get(at)) { + _ if i == target => revolve_options(p), + Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)), + _ => continue, + }; + let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); + let b = path.bounds().ok_or(EMPTY)?; + check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + } + Ok(()) +} + fn has_two_anchors(path: &PathData) -> bool { path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) } @@ -250,12 +282,13 @@ fn surface( Some((depths, group)) } -/// A surface of one object: `shape`'s own, built with options derived from `fx`, about `b`. -fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool) -> Option { +/// A surface of one object about the bounds of `path`, built with options derived from `fx`. +/// `copies` is the count [`surface`] adds to. +fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); let options = revolve_options(&merged_params(REVOLVE, &fx.params)); - surface(n, path, b, options, fx, paint, opacity, blend, expand, &mut 0).map(|(_, art)| art) + surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -357,6 +390,7 @@ fn art(n: &Node, expand: bool) -> Option { let (path, _) = profile(n)?; let ctx = GeomContext::of(n); let mut children = vec![]; + let mut copies = 0; let mut remaining = n.appearance.effects.clone(); if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { let fx = n.appearance.effects.get(index)?; @@ -365,7 +399,7 @@ fn art(n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - children.push(Arc::new(object_surface(n, &path, fx, item, expand)?)); + children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = n .appearance @@ -383,7 +417,7 @@ fn art(n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = object_surface(n, &p, fx, item, expand)?; + let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); children.push(Arc::new(art)); } else { @@ -721,6 +755,55 @@ mod tests { assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget"); } + #[test] + fn the_fills_of_one_object_share_the_budget_of_paint_copies() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let source = profile_node(); + let NodeKind::Path { path: profile, .. } = &source.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len(); + let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::(); + let fill = |stops: usize, revolved: bool| { + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut item = AppearanceItem::Fill(FillLayer::new(paint)); + if revolved { + item.effects_mut().push(fx()); + } + item + }; + let with = |items: Vec| { + let mut n = source.clone(); + n.appearance = Appearance { items, ..Default::default() }.into(); + n + }; + let one = with(vec![fill(stops, true)]); + assert!(revolve_art(&one).is_some()); + assert_eq!(validate_item_revolve(&one, &json!({}), 0, 0), Ok(())); + let two = with(vec![fill(stops, true), fill(stops, true)]); + assert_eq!(revolve_art(&two), None, "past the budget the source draws"); + let refused = validate_item_revolve(&two, &json!({}), 1, 0).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + let second = with(vec![fill(stops, true), fill(stops, false)]); + assert!(validate_item_revolve(&second, &json!({}), 1, 0).is_err(), "the item the effect goes on counts"); + assert_eq!(validate_item_revolve(&second, &json!({}), 0, 0), Ok(()), "an item without a Revolve does not"); + let below = with(vec![fill(stops * 2, false), fill(1, false)]); + assert!(validate_item_revolve(&below, &json!({}), 0, 0).is_err(), "the item's own paint, not the top fill"); + assert_eq!(validate_item_revolve(&below, &json!({}), 1, 0), Ok(())); + } + + #[test] + fn an_items_revolve_is_validated_after_only_the_geometry_effects_before_it() { + let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); + let mut object = (*star(1)).clone(); + let mut hidden = fx(); + hidden.visible = false; + let item = object.appearance.items.first_mut().unwrap(); + item.effects_mut().push(hidden); + item.effects_mut().push(roughen); + assert_eq!(validate_item_revolve(&object, &json!({}), 0, 0), Ok(()), "the roughen after it is not swept"); + assert!(validate_item_revolve(&object, &json!({}), 0, 2).is_err(), "a Revolve added after the roughen"); + } + #[test] fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() { let mut n = profile_node(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3330cde75..2340446f2 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -298,6 +298,17 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) } } +/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of +/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself +/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the +/// item's geometry effects before it, together with the object's other items that carry a Revolve. +pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { + match at { + Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), + _ => validate_three_d(n, id, p), + } +} + /// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not /// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does /// not need to fit. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 028996f61..9d396edc1 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -280,9 +280,13 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?; if effects::is_three_d(id) { - for target in appearance_targets(s, p)? { + let targets = appearance_targets(s, p)?; + for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?; - effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?; + // A new effect goes after the item's others. + let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); + let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len()))); + effects::validate_three_d_at(n, id, &effect.params, on).map_err(|e| bad(C, e))?; if id == effects::REVOLVE && effects::has_revolve(n) { return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); } @@ -589,7 +593,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { cur.extend(new); } if visible != Some(false) { - effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; + effects::validate_three_d_at(n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; } else { // Hidden, the effect draws nothing, so its object need not fit. The values given are // checked, and after a change of 3D type so is every value it keeps. diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index eaca5d114..d154c9cfb 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -260,6 +260,31 @@ fn revolve_refuses_type_and_groups_without_a_shape() { assert!(node(&s, g).appearance.effects.is_empty()); } +/// A Revolve on one fill is checked in that fill's paint, and `effect.setParams` on it as well. +#[test] +fn revolve_on_a_fill_is_checked_in_that_fills_paint() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{AppearanceItem, FillLayer}; + let (mut s, id) = session_with_rect(); + // The paint copies of 100 faces reach the 256 MiB budget: 8 segments stay under, 64 pass it. + let count = (256 << 20) / (4 * 100 * std::mem::size_of::()); + let stops = (0..count).map(|i| GradientStop::new(i as f32 / count as f32, Color::rgb(1.0, 0.0, 0.0))).collect(); + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("fills", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.items = + vec![AppearanceItem::Fill(FillLayer::new(ramp)), AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))]; + Ok(()) + }) + .unwrap(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0})).unwrap_err().to_string(); + assert!(err.contains("simplify the gradient"), "{err}"); + assert!(node(&s, id).appearance.items[0].effects().is_empty()); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0, "params": {"segments": 8}})).unwrap(); + let err = s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": 0, "params": {"segments": 128}})).unwrap_err().to_string(); + assert!(err.contains("simplify the gradient"), "{err}"); +} + /// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps /// each member's paint, follows an edited member, expands and undoes as one step. #[test] diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 27706f0b5..51f7c59e9 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -297,13 +297,15 @@ fn the_wall_of_a_counter_faces_into_the_hole() { }; for (clockwise, rule) in [(true, FillRule::NonZero), (true, FillRule::EvenOdd), (false, FillRule::EvenOdd)] { let m = ring(clockwise, rule); - assert!(wall_directions(&m, 110.0).iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}"); + let outer = wall_directions(&m, 110.0); + assert!(!outer.is_empty() && outer.iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}"); let hole = wall_directions(&m, 90.0); assert!(!hole.is_empty() && hole.iter().all(|s| *s < 0.0), "the hole's far wall faces the axis, {clockwise} {rule:?}"); } // Wound the same way under the nonzero rule, the inner square is not a hole. let m = ring(false, FillRule::NonZero); - assert!(wall_directions(&m, 90.0).iter().all(|s| *s > 0.0)); + let inner = wall_directions(&m, 90.0); + assert!(!inner.is_empty() && inner.iter().all(|s| *s > 0.0)); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 7ab4e07de..39c746df1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2352,7 +2352,7 @@ compound path, type, or a group or layer of them. `effect.dialog` opens compact `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. -A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` diff --git a/docs/revolve.md b/docs/revolve.md index 836c3faa3..fd0f36f16 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -3,9 +3,9 @@ This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). Select a path, compound path, type, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. -Type stays editable and sweeps as its glyph outlines. On a group or layer, every visible member sweeps about one axis -on the chosen edge of the members' bounds together, in its own paint, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) -describes for a group. +On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) +describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, +the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. From cddebdf7f67ae206ccc4c78020747ec8d886539f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:51:22 -0800 Subject: [PATCH 09/48] 3D: a group's fill or stroke takes no 3D effect Revolve, Extrude & Bevel, Inflate and Rotate on a group or layer draw only as the group's own effect. On the group's fill or stroke they drew nothing, yet effect.apply and effect.setParams accepted them. Both now refuse that placement. --- crates/effects/src/revolve.rs | 11 +++++++++++ crates/effects/src/solids.rs | 2 ++ docs/mcp.md | 3 ++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index cf275fc2d..58125c661 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -717,6 +717,17 @@ mod tests { assert_eq!(revolve_art(&both), None, "past the limits the source draws"); } + #[test] + fn a_3d_effect_on_a_groups_fill_is_refused() { + let mut group = Node::group(NodeId(9), vec![member(1, 0.0)]); + group.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into(); + for id in [REVOLVE, crate::EXTRUDE] { + assert!(crate::validate_three_d(&group, id, &json!({})).is_ok(), "{id} on the group itself"); + let refused = crate::validate_three_d_at(&group, id, &json!({}), Some((0, 0))).unwrap_err(); + assert!(refused.contains("on the group itself"), "{id}: {refused}"); + } + } + #[test] fn validation_measures_the_profile_after_the_geometry_effects_before_revolve() { let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 2340446f2..568bf2be9 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -302,8 +302,10 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself /// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the /// item's geometry effects before it, together with the object's other items that carry a Revolve. +/// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws. pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { match at { + Some(_) if is_container(n) => Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()), Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), _ => validate_three_d(n, id, p), } diff --git a/docs/mcp.md b/docs/mcp.md index b76f2da5a..499d02bd4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2461,7 +2461,8 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies the other 3D types to a path or compound path, live like Revolve (the source stays editable; SVG/PDF exports and -`effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included. +`effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included. On a group or +layer the 3D effect goes on the group itself: `effect.apply` and `effect.setParams` refuse one on its fill or stroke. `threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth` From 86184868a52be1d4e03210fe013516298c2578a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:16:43 -0800 Subject: [PATCH 10/48] New Document: More Settings is a pop-up, and Save Preset a mode of Preset Details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More Settings no longer replaces the dialog with a form that repeats Preset Details. It opens a pop-up over New Document with only Number of Artboards, the four layout buttons, Change to Right-to-Left Layout, Spacing and Columns, editing the same fields live. Done, Escape and a click outside close it and keep the values; the click outside does not reach the dialog under it. Enter in a field applies it and closes the pop-up. The old form's Templates… button is removed with it; File > New from Template opens templates. Save Preset no longer adds a Preset Name row under the document's name. It replaces the top of Preset Details with a Save Document Preset heading, the preset's name (focused, text selected), Cancel and Save, and hides the document's name, Close and Create. The rows under it stay where they are and editable, and Save stores them. Enter in the name saves; Escape is Cancel and keeps the edits. Nothing creates a document in this mode: a double-clicked preset card, Enter elsewhere and ui.dialog.confirm (which saves). A single click on a card fills the fields, as outside the mode. Save Preset's default name is now the first "Preset N" that no saved preset has, and it is offered again after each save. Before, it was "Preset ", and after a save the field kept the saved name, so pressing Save could replace an existing preset. A name typed by hand still replaces a preset of the same name, as before. Enter creates the document on a new press only, so a held Enter's repeats after a save do not create one. The newDocumentMore dialog kind, presetName and __saving keep working for the control protocol (ui.click on More Settings opens the pop-up); docs/control-protocol.md describes them. The catalog rows of the removed form's strings are removed. --- crates/ui-egui/src/dialogs.rs | 6 +- crates/ui-egui/src/dialogs/flatten.rs | 15 + crates/ui-egui/src/dialogs/new_document.rs | 884 +++++++++++++----- .../src/dialogs/tests_accessibility.rs | 10 +- crates/ui-egui/src/i18n/bg.tsv | 5 +- crates/ui-egui/src/i18n/bs.tsv | 5 +- crates/ui-egui/src/i18n/ca.tsv | 5 +- crates/ui-egui/src/i18n/cs.tsv | 5 +- crates/ui-egui/src/i18n/da.tsv | 5 +- crates/ui-egui/src/i18n/de.tsv | 5 +- crates/ui-egui/src/i18n/el.tsv | 5 +- crates/ui-egui/src/i18n/es.tsv | 5 +- crates/ui-egui/src/i18n/fi.tsv | 5 +- crates/ui-egui/src/i18n/fr.tsv | 5 +- crates/ui-egui/src/i18n/hi.tsv | 5 +- crates/ui-egui/src/i18n/hu.tsv | 5 +- crates/ui-egui/src/i18n/id.tsv | 5 +- crates/ui-egui/src/i18n/it.tsv | 5 +- crates/ui-egui/src/i18n/ja.tsv | 5 +- crates/ui-egui/src/i18n/ka.tsv | 5 +- crates/ui-egui/src/i18n/ko.tsv | 5 +- crates/ui-egui/src/i18n/nl.tsv | 5 +- crates/ui-egui/src/i18n/pl.tsv | 5 +- crates/ui-egui/src/i18n/pt-br.tsv | 5 +- crates/ui-egui/src/i18n/ru.tsv | 5 +- crates/ui-egui/src/i18n/sv.tsv | 5 +- crates/ui-egui/src/i18n/tr.tsv | 5 +- crates/ui-egui/src/i18n/uk.tsv | 5 +- crates/ui-egui/src/i18n/vi.tsv | 5 +- crates/ui-egui/src/i18n/zh-hans.tsv | 5 +- crates/ui-egui/src/i18n/zh-hant.tsv | 5 +- crates/ui-egui/src/shortcuts.rs | 4 +- docs/control-protocol.md | 12 +- docs/target-app-parity.md | 1 + 34 files changed, 719 insertions(+), 348 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1f59647c1..9137442f0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -152,8 +152,8 @@ pub(crate) use export_for_screens::{ saved_options as screen_saved_options, saved_rows as screen_saved_rows, }; pub(crate) use form::{humanize, number_list_text, plugin_color_button, read_number_list, unit_suffix}; -pub(crate) use new_document::preset_name; pub use new_document::{create_without_dialog as new_document_without_dialog, open as open_new_document, preset_card}; +pub(crate) use new_document::{popup_open as new_document_popup_open, preset_name}; pub use png_options::open as open_raster_options; pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset}; pub use tools::open_tool_dialog; @@ -560,6 +560,10 @@ pub(crate) fn field_set(app: &mut VectorcraftApp, field: &str, old: Option<&Valu /// opened over (`ui.dialog.cancel`). pub fn cancel(app: &mut VectorcraftApp) { missing_fonts::requeue_replaced(app); + // More Settings' pop-up and Save Preset end on their own; New Document stays. + if app.ui.dialog.as_mut().is_some_and(new_document::close_popup) { + return; + } if let Some(d) = app.ui.dialog.take() { if d.kind == swatch_conflict::KIND { crate::placing::cancel_swatch_paste(app); diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index 3cd0a5111..485934893 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -316,6 +316,21 @@ mod tests { assert!(!transparent_left(&app)); } + /// Cancel with the Save Preset row showing closes the dialog and rolls the preview back. + #[test] + fn cancel_while_saving_a_preset_closes_the_dialog() { + let mut app = scene(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("ui.flattenTransparencyDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + frame(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert(SAVING.into(), json!(true)); + frame(&mut app); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc, before, "Cancel rolls back"); + } + #[test] fn ok_says_in_the_status_bar_when_an_image_could_not_be_decoded() { const WARNING: &str = "an image couldn't be decoded and is missing from the picture"; diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 982dd7c6f..8e0200933 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -1,14 +1,16 @@ //! File → New: category tabs (Recent, Saved and the built-in categories of `file.newPresets`) //! over a grid of preset cards, and a Preset Details column (name and Save Preset, size and //! units, orientation, artboards, bleed, background contents, Advanced Options, More Settings); -//! Close and Create. More Settings is the classic form of the same fields (artboard layout, -//! spacing and columns, profile and size lists), so both share one set of fields and widgets. +//! Close and Create. More Settings opens a pop-up over the dialog with the artboard layout, spacing +//! and columns, editing the same fields. Save Preset turns the top of Preset Details into the +//! preset's name, Cancel and Save, and hides Close and Create until the preset is saved or canceled; +//! the rows under it stay editable and are what the preset stores. //! //! Fields: `file.new`'s params (`preset`, `name`, `width`, `height` (pt), `units`, `artboards`, //! `artboardLayout`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi`, -//! `previewMode`), plus `category` (the tab, also More Settings' Profile), `advanced` (Advanced -//! Options open), `bleedLinked` and `presetName` (Save Preset's name). Setting `preset` fills the -//! fields from that preset; changing any of them afterwards makes the settings custom. +//! `previewMode`), plus `category` (the tab), `advanced` (Advanced Options open), `bleedLinked` and +//! `presetName` (Save Preset's name). Setting `preset` fills the fields from that preset; changing +//! any of them afterwards makes the settings custom. use serde_json::{Map, Value, json}; use vectorcraft_doc::Unit; @@ -20,11 +22,11 @@ use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, widgets}; pub(super) const KIND: &str = "newDocument"; -/// More Settings. +/// New Document with its More Settings pop-up open. pub(super) const MORE: &str = "newDocumentMore"; pub(super) const SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], ..DialogSpec::window(show, confirm) }; -pub(super) const MORE_SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], ..DialogSpec::window(show_more, confirm) }; +pub(super) const MORE_SPEC: DialogSpec = SPEC; /// The fields `file.new` takes. const PARAMS: [&str; 12] = [ @@ -44,8 +46,11 @@ const PARAMS: [&str; 12] = [ /// The preset the fields were last filled from, as `apply` set them; a Recent or Saved card keeps /// its own units here, which the units chosen in Preset Details don't change. const APPLIED: &str = "__applied"; -/// Save Preset's name field is showing. +/// Preset Details is saving a preset (Save Preset's name, Cancel and Save are showing). const SAVING: &str = "__saving"; +/// Preset Details has drawn the saving top: set from the mode's second frame, so the first one fills +/// in the default name and focuses it. +const SAVING_SHOWN: &str = "__savingShown"; /// The units New Document offers (all ten). const UNITS: [Unit; 10] = [ Unit::Pixels, @@ -65,6 +70,10 @@ const COLOR_MODES: [(&str, &str); 2] = [("rgb", "RGB Color"), ("cmyk", "CMYK Col const DETAILS: f32 = 292.0; /// Height of the dialog's content. const HEIGHT: f32 = 600.0; +/// Height of the top of Preset Details in either state: the rows under it don't move. +const HEADER: f32 = 100.0; +/// Width of the More Settings pop-up's rows. +const MORE_WIDTH: f32 = 310.0; /// Open New Document on the most recent settings (or Letter), named after the next untitled /// document. @@ -116,8 +125,17 @@ fn sync(app: &VectorcraftApp, d: &mut Dialog) { } } -/// Create the document; the dialog stays open when that fails (bad size, …). +/// Create the document; the dialog stays open when that fails (bad size, …). While a preset is being +/// saved this is its Save, and no document is created. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.bool(SAVING) { + let mut d = d.clone(); + let r = save_preset(app, &mut d); + if r.is_ok() { + app.ui.dialog = Some(d); + } + return r; + } let p = params(app, d); let r = app.run("file.new", Value::Object(p)); if r.is_ok() { @@ -175,17 +193,45 @@ fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui: super::modal::show(ctx, id, egui::Id::unique(("dialog", id)), -20.0, margin, add); } +/// Whether More Settings' pop-up is open over New Document. +pub(crate) fn popup_open(d: &Dialog) -> bool { + d.kind == MORE +} + +/// Close New Document's More Settings pop-up, or else leave its Save Preset, keeping the fields. +/// False when neither is showing, or `d` is another dialog. +pub(super) fn close_popup(d: &mut Dialog) -> bool { + if d.kind != KIND && d.kind != MORE { + return false; + } + if popup_open(d) { + d.kind = KIND.into(); + return true; + } + let saving = d.bool(SAVING); + if saving { + d.fields.insert(SAVING.into(), json!(false)); + d.fields.remove(SAVING_SHOWN); + } + saving +} + /// What the buttons of a window asked for. #[derive(Default)] struct Buttons { create: bool, close: bool, + /// More Settings' pop-up was open as the frame began: Enter is the pop-up's. + popup: bool, + /// A preset was being saved as the frame began: nothing creates the document this frame. + saving: bool, } impl Buttons { /// Act on the buttons (Enter also creates) once the window is drawn. fn finish(self, app: &mut VectorcraftApp, ctx: &egui::Context, d: Dialog) { - let create = self.create || ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.memory(|m| m.focused().is_some()); + let enter = !self.popup && crate::shortcuts::pressed_anew(ctx, egui::Key::Enter) && !ctx.memory(|m| m.focused().is_some()); + let create = !self.saving && !d.bool(SAVING) && (self.create || enter); app.ui.dialog = Some(d); if self.close { app.ui.dialog = None; @@ -199,7 +245,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; sync(app, &mut d); let t = Tokens::get(ctx); - let mut b = Buttons::default(); + let mut b = Buttons { popup: popup_open(&d), saving: d.bool(SAVING), ..Default::default() }; window(ctx, KIND, 0, |ui| { // No heading: the top margin (14) and the empty part of the category tabs' row (30) move the // window. @@ -220,6 +266,10 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); }); + // While More Settings' pop-up is open, a click outside it only closes it. + if b.popup { + ui.interact(ui.min_rect(), ui.scope_id().with("newdoc-popup-shield"), egui::Sense::CLICK | egui::Sense::DRAG); + } }); b.finish(app, ctx, d); } @@ -356,17 +406,24 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut let t = Tokens::get(ui.ctx()); ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0); let top = ui.cursor().top(); - ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim)); - ui.add_space(8.0); - let caption = form::caption(ui, tl!("Document Name")); - ui.horizontal(|ui| { - ui.label_inputs_by(caption.id, |ui| form::text(ui, d, "name", DETAILS - 50.0)); - save_button(app, ui, d); - }); - if d.bool(SAVING) { - save_row(app, ui, d); + let saving = d.bool(SAVING); + if saving { + let opening = !d.bool(SAVING_SHOWN); + d.fields.insert(SAVING_SHOWN.into(), json!(true)); + save_header(app, ui, d, opening); + } else { + d.fields.remove(SAVING_SHOWN); + ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim)); + ui.add_space(8.0); + let caption = form::caption(ui, tl!("Document Name")); + ui.horizontal(|ui| { + ui.label_inputs_by(caption.id, |ui| form::text(ui, d, "name", DETAILS - 50.0)); + if widgets::icon_button(ui, "save", tl!("Save Preset"), false, 26.0).clicked() { + d.fields.insert(SAVING.into(), json!(true)); + } + }); } - ui.add_space(8.0); + ui.add_space((HEADER - (ui.cursor().top() - top)).max(0.0)); let unit = unit(d); let caption = form::caption(ui, tl!("Width")); ui.horizontal(|ui| { @@ -387,7 +444,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut ui.add_space(6.0); ui.vertical(|ui| { let caption = form::caption(ui, tl!("Artboards")); - ui.label_inputs_by(caption.id, |ui| artboards(ui, d, 76.0)); + ui.label_inputs_by(caption.id, |ui| artboards(ui, d, "newdoc-artboards", 76.0)); }); }); ui.add_space(8.0); @@ -399,8 +456,9 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut ui.add_space(8.0); advanced(ui, d, 110.0, DETAILS - 116.0); ui.add_space(12.0); - if widgets::flat_button(ui, tl!("More Settings"), DETAILS).clicked() { - d.kind = MORE.into(); + more_settings(ui, d); + if saving { + return; } // Close and Create at the bottom right. ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0)); @@ -411,44 +469,159 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut }); } -/// The Save Preset button beside the name: it shows or hides [`save_row`]. -fn save_button(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - if widgets::icon_button(ui, "save", tl!("Save Preset"), d.bool(SAVING), 26.0).clicked() { - let saving = !d.bool(SAVING); - d.fields.insert(SAVING.into(), json!(saving)); - if saving && d.str("presetName").is_empty() { - let template = "Preset {number}"; - let name = crate::i18n::fmt(tl!(template), &[("number", &(app.session.prefs.new_doc_presets.len() + 1).to_string())]); - d.fields.insert("presetName".into(), json!(name)); - } +/// The key under which a layer remembers that its first field should take the keyboard focus. +fn pending_focus(ui: &egui::Ui) -> egui::Id { + ui.layer_id().id.with("newdoc-focus") +} + +/// Draw `add`; its first enabled field takes the keyboard focus, with its text selected, when +/// `opening` says so or a failed Save asked for it. +fn focus_first(ui: &mut egui::Ui, opening: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let pending = pending_focus(ui); + if opening { + ui.data_mut(|m| m.insert_temp(pending, true)); + } + // The first frame of a new window only measures it: no field can take the focus until the next. + let focus = !ui.is_sizing_pass() && ui.data(|m| m.get_temp::(pending)).unwrap_or(false); + if focus { + ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true)); + } + let r = add(ui); + if focus && ui.data_mut(|m| m.remove_temp::(widgets::dialog_focus_flag())).is_none() { + ui.data_mut(|m| m.remove::(pending)); + } + r +} + +/// The top of Preset Details while a preset is being saved: the heading, the preset's name, Cancel +/// and Save. Enter in the name is Save. `opening` on the mode's first frame. +fn save_header(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, opening: bool) { + let t = Tokens::get(ui.ctx()); + if opening && d.str("presetName").is_empty() { + d.fields.insert("presetName".into(), json!(new_preset_name(app))); + } + ui.label(egui::RichText::new(tl!("Save Document Preset")).font(theme::semibold(13.0)).color(t.text)); + ui.add_space(5.0); + let name = focus_first(ui, opening, |ui| form::text_edit_named(ui, d, "presetName", tl!("Preset Name"), DETAILS - 14.0)); + let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.add_space(4.0); + let (mut save, mut cancel) = (enter, false); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + save |= widgets::primary_button(ui, tl!("Save")).clicked(); + ui.add_space(8.0); + cancel = widgets::secondary_button(ui, tl!("Cancel")).clicked(); + }); + if cancel { + d.fields.insert(SAVING.into(), json!(false)); + d.fields.remove(SAVING_SHOWN); + } else if save && let Err(e) = save_preset(app, d) { + app.status(e); + let pending = pending_focus(ui); + ui.data_mut(|m| m.insert_temp(pending, true)); } } -/// Save Preset: the preset's name, Save and Cancel. -fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.add_space(4.0); - let caption = form::caption(ui, tl!("Preset Name")); - ui.horizontal(|ui| { - let name = ui.label_inputs_by(caption.id, |ui| form::text_edit(ui, d, "presetName", DETAILS - 140.0)); - let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if widgets::flat_button(ui, tl!("Save"), 54.0).clicked() || enter { - let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); - p.insert("name".into(), json!(d.str("presetName"))); - p.remove("preset"); - match app.run("file.newPresets.save", Value::Object(p)) { - Ok(r) => { - d.fields.insert(SAVING.into(), json!(false)); - d.fields.insert("category".into(), json!("Saved")); - d.fields.insert("preset".into(), r["name"].clone()); - d.fields.remove(APPLIED); +/// Save Preset's default name: the first "Preset N" no saved preset has (`file.newPresets.save` +/// replaces a preset of the same name). +fn new_preset_name(app: &VectorcraftApp) -> String { + let saved = &app.session.prefs.new_doc_presets; + let template = "Preset {number}"; + (1..=saved.len() + 1) + .map(|n| crate::i18n::fmt(tl!(template), &[("number", &n.to_string())])) + .find(|name| !saved.iter().any(|p| p.name.trim().eq_ignore_ascii_case(name.trim()))) + .unwrap_or_default() +} + +/// Save the fields as the preset named `presetName` and leave saving, on the saved preset. +fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result { + let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + p.insert("name".into(), json!(d.str("presetName"))); + p.remove("preset"); + let r = app.run("file.newPresets.save", Value::Object(p))?; + d.fields.insert(SAVING.into(), json!(false)); + d.fields.remove(SAVING_SHOWN); + // The next Save Preset offers a new default name instead of this preset's. + d.fields.remove("presetName"); + d.fields.insert("category".into(), json!("Saved")); + d.fields.insert("preset".into(), r["name"].clone()); + d.fields.remove(APPLIED); + Ok(r) +} + +/// The More Settings button and its pop-up, which holds what Preset Details doesn't: the artboard +/// layout, spacing and columns. Done, a click outside it and Escape close it; the fields it edited +/// stay as they are. +fn more_settings(ui: &mut egui::Ui, d: &mut Dialog) { + let button = widgets::flat_button(ui, tl!("More Settings"), DETAILS); + let mut open = (d.kind == MORE) != button.clicked(); + let mut done = false; + let opening = button.ctx.read_response(egui::Popup::default_response_id(&button)).is_none(); + egui::Popup::from_response(&button) + .open_bool(&mut open) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .align(egui::RectAlign::TOP_END) + .accessible_name(tl!("More Settings")) + .show(|ui| done = more_settings_popup(ui, opening, d)); + d.kind = if open && !done { MORE } else { KIND }.into(); +} + +/// More Settings' pop-up. True when Done (or Enter) finished with it. +fn more_settings_popup(ui: &mut egui::Ui, opening: bool, d: &mut Dialog) -> bool { + const L: f32 = 150.0; + let t = Tokens::get(ui.ctx()); + let (lay, cols, spacing, rtl) = layout(d); + let n = d.f64("artboards", 1.0); + let several = n > 1.0; + let room = ui.ctx().content_rect().size() - egui::Vec2::splat(4.0 * super::EDGE_GAP) - egui::vec2(0.0, 44.0); + focus_first(ui, opening, |ui| { + egui::ScrollArea::both().id_salt("newdoc-popup-rows").max_width(room.x.max(0.0)).max_height(room.y.max(0.0)).show(ui, |ui| { + ui.set_width(MORE_WIDTH); + ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(13.0)).color(t.text)); + ui.add_space(6.0); + widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| artboards(ui, d, "newdoc-popup-artboards", 76.0)); + widgets::label_row(ui, "", L, |ui| { + for (l, icon) in [ + (ArtboardLayout::GridByRow, "dc-grid-view"), + (ArtboardLayout::GridByColumn, "grid-3x3"), + (ArtboardLayout::Row, "arrow-left-right"), + (ArtboardLayout::Column, "arrow-up-down"), + ] { + if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() { + set_layout(d, "layout", json!(l.id())); + } } - Err(e) => app.status(e), - } - } - if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() { - d.fields.insert(SAVING.into(), json!(false)); - } + ui.add_space(6.0); + if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() { + set_layout(d, "rightToLeft", json!(!rtl)); + } + }); + ui.add_enabled_ui(several, |ui| { + widgets::label_row(ui, tl!("Spacing:"), L, |ui| { + if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(d), 100.0) { + set_layout(d, "spacing", json!(v.max(0.0))); + } + }); + let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn); + ui.add_enabled_ui(grid, |ui| { + widgets::label_row(ui, tl!("Columns:"), L, |ui| { + if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) { + set_layout(d, "columns", json!(v.round().clamp(1.0, n) as u64)); + } + }); + }); + }); + }); }); + ui.add_space(8.0); + // A bad value in a field keeps the pop-up open, as it keeps a dialog open. + let rejected = ui.data_mut(|m| m.remove_temp::(widgets::dialog_reject_commit_flag())).unwrap_or(false); + let enter = !rejected && ui.input(|i| i.key_pressed(egui::Key::Enter)) && ui.memory(|m| m.focused().is_none()); + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + let mut done = false; + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { + done = widgets::primary_button(ui, tl!("Done")).clicked() || enter; + }); + done } fn units_dropdown(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { @@ -481,10 +654,10 @@ fn orientation(ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn artboards(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { +fn artboards(ui: &mut egui::Ui, d: &mut Dialog, id: &str, width: f32) { let n = d.f64("artboards", 1.0); ui.horizontal(|ui| { - if let Some(v) = widgets::spin_plain(ui, "newdoc-artboards", n, "", 0, width, 1.0, 1.0, &[]) { + if let Some(v) = widgets::spin_plain(ui, id, n, "", 0, width, 1.0, 1.0, &[]) { d.fields.insert("artboards".into(), json!(v.round().clamp(1.0, MAX_ARTBOARDS as f64) as u64)); } }); @@ -543,131 +716,10 @@ fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) { } } -/// More Settings: the classic form of the same fields. -fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) { - let Some(mut d) = app.ui.dialog.clone() else { return }; - sync(app, &mut d); - let t = Tokens::get(ctx); - let mut b = Buttons::default(); - let mut templates = false; - const L: f32 = 150.0; - window(ctx, MORE, 22, |ui| { - ui.set_width(560.0); - super::modal::heading(ui, tl!("More Settings")); - ui.add_space(12.0); - widgets::label_row(ui, tl!("Name:"), L, |ui| { - form::text(ui, &mut d, "name", 300.0); - save_button(app, ui, &mut d); - }); - if d.bool(SAVING) { - // Under the name, in the fields' column. - widgets::label_row(ui, "", L, |ui| { - ui.vertical(|ui| save_row(app, ui, &mut d)); - }); - } - // Profile: the preset categories (the tabs); Size: the profile's presets. - let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect(); - let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]"); - widgets::label_row(ui, tl!("Profile:"), L, |ui| { - if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) { - d.fields.insert("category".into(), json!(p)); - if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) { - apply(&mut d, &first); - } - } - }); - ui.add_space(6.0); - let (lay, cols, spacing, rtl) = layout(&d); - let n = d.f64("artboards", 1.0); - let several = n > 1.0; - widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| { - artboards(ui, &mut d, 76.0); - ui.add_space(10.0); - for (l, icon) in [ - (ArtboardLayout::GridByRow, "dc-grid-view"), - (ArtboardLayout::GridByColumn, "grid-3x3"), - (ArtboardLayout::Row, "arrow-left-right"), - (ArtboardLayout::Column, "arrow-up-down"), - ] { - if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() { - set_layout(&mut d, "layout", json!(l.id())); - } - } - ui.add_space(6.0); - if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() { - set_layout(&mut d, "rightToLeft", json!(!rtl)); - } - }); - ui.add_enabled_ui(several, |ui| { - widgets::label_row(ui, tl!("Spacing:"), L, |ui| { - if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) { - set_layout(&mut d, "spacing", json!(v.max(0.0))); - } - ui.add_space(20.0); - widgets::field_label(ui, egui::RichText::new(tl!("Columns:")).color(t.text)); - let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn); - ui.add_enabled_ui(grid, |ui| { - if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) { - set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64)); - } - }); - }); - }); - ui.add_space(6.0); - let sizes = newdoc::category(&app.session, profile).unwrap_or_default(); - let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect(); - let preset = d.str("preset"); - widgets::label_row(ui, tl!("Size:"), L, |ui| { - // The profile's sizes are built in; the preset may be one the user saved. - let shown = if preset.is_empty() { tl!("Custom") } else { preset_name(&preset) }; - if let Some(s) = super::mixed_dropdown(ui, "newdoc-size", shown, &size_names, 220.0, |_| true).and_then(|i| sizes.get(i)) { - apply(&mut d, s); - } - }); - let unit = unit(&d); - widgets::label_row(ui, tl!("Width:"), L, |ui| { - form::length(ui, &mut d, "width", unit, 120.0); - ui.add_space(20.0); - ui.label(egui::RichText::new(tl!("Units:")).color(t.text)); - units_dropdown(ui, &mut d, 120.0); - }); - widgets::label_row(ui, tl!("Height:"), L, |ui| { - form::length(ui, &mut d, "height", unit, 120.0); - ui.add_space(20.0); - ui.label(egui::RichText::new(tl!("Orientation:")).color(t.text)); - orientation(ui, &mut d); - }); - ui.add_space(4.0); - widgets::label_row(ui, tl!("Bleed:"), L, |ui| form::bleed(ui, &mut d, unit, 64.0)); - ui.add_space(4.0); - widgets::label_row(ui, tl!("Background Contents:"), L, |ui| background(ui, &mut d, 220.0)); - ui.add_space(8.0); - advanced(ui, &mut d, L, 220.0); - ui.add_space(16.0); - ui.horizontal(|ui| { - templates = widgets::secondary_button(ui, tl!("Templates…")).on_hover_text(tl!("New from Template")).clicked(); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - b.create |= widgets::primary_button(ui, tl!("Create Document")).clicked(); - ui.add_space(8.0); - // Cancel goes back to New Document. - if widgets::secondary_button(ui, tl!("Cancel")).clicked() { - d.kind = KIND.into(); - } - }); - }); - }); - if templates { - app.ui.dialog = None; - if let Err(e) = app.run("file.newFromTemplate", json!({})) { - app.status(e); - } - return; - } - b.finish(app, ctx, d); -} - #[cfg(test)] mod tests { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; use serde_json::json; use vectorcraft_engine::Session; @@ -792,6 +844,26 @@ mod tests { run(&mut app, vec![press(false)]); assert!(app.ui.dialog.is_none(), "the double-click created it: {:?}", app.ui.status); assert_eq!(app.session.documents().len(), 1); + + // While a preset is being saved, a click fills the fields and a double-click creates nothing. + app.run("file.newDialog", json!({})).unwrap(); + set(&mut app, "category", json!("Mobile")); + set(&mut app, "preset", json!("Letter")); + set(&mut app, super::SAVING, json!(true)); + // Far enough from the clicks above that the next two count as a click and a double-click. + for _ in 0..12 { + run(&mut app, vec![]); + } + let at = card(&mut app, &mut run); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + run(&mut app, vec![egui::Event::PointerMoved(at), press(true)]); + run(&mut app, vec![press(false)]); + assert!(field(&app, "preset").as_str().is_some_and(|p| p == "Phone 393×852"), "a click fills the fields: {:?}", field(&app, "preset")); + run(&mut app, vec![press(true)]); + run(&mut app, vec![press(false)]); + run(&mut app, vec![]); + assert_eq!(app.session.documents().len(), 1, "nothing was created while saving"); + assert_eq!(field(&app, super::SAVING), json!(true)); } /// A category tab opens on its first preset, so Orientation matches its (landscape) cards, and @@ -878,14 +950,14 @@ mod tests { assert!(selected(&out), "the turned preset stays chosen: {:?}", field(&app, "preset")); } - /// The document's name field has a caption of its own under the Preset Details heading, and - /// the column stays inside the dialog's height with Save Preset and Advanced Options open. + /// The document's name field has a caption of its own under the Preset Details heading, the + /// column stays inside the dialog's height with Advanced Options open in both states, and saving + /// a preset replaces only the top of the column: the rows under it do not move. #[test] fn the_name_field_is_captioned_and_the_column_fits() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.newDialog", json!({})).unwrap(); set(&mut app, "advanced", json!(true)); - set(&mut app, super::SAVING, json!(true)); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut time = 0.0; @@ -897,8 +969,6 @@ mod tests { out.textures_delta.clear(); out }; - run(&mut app); - let out = run(&mut app); fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { match s { egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), @@ -906,13 +976,33 @@ mod tests { _ => {} } } - let mut texts = vec![]; - out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); - let at = |name: &str| texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} is missing: {texts:?}")); - let (heading, caption, value) = (at("PRESET DETAILS"), at("Document Name"), at("Untitled-1")); + let mut texts_of = |app: &mut VectorcraftApp| { + run(app); + let out = run(app); + let mut texts = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); + texts + }; + let texts = texts_of(&mut app); + let at = |texts: &[(String, egui::Rect)], name: &str| { + texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} is missing: {texts:?}")) + }; + let (heading, caption, value) = (at(&texts, "PRESET DETAILS"), at(&texts, "Document Name"), at(&texts, "Untitled-1")); assert!(heading.bottom() <= caption.top() && caption.bottom() <= value.top(), "{heading:?} {caption:?} {value:?}"); - assert!(value.bottom() <= at("Preset Name").top(), "the preset's own name field follows"); - let used = at("Create").bottom() - heading.top(); + let used = at(&texts, "Create").bottom() - heading.top(); + assert!(used <= super::HEIGHT, "the column used {used}"); + let width = at(&texts, "Width").top(); + + set(&mut app, super::SAVING, json!(true)); + let saving = texts_of(&mut app); + for gone in ["PRESET DETAILS", "Document Name", "Close", "Create"] { + assert!(saving.iter().all(|(t, _)| t != gone), "{gone} shows while saving"); + } + let (heading, cancel) = (at(&saving, "Save Document Preset"), at(&saving, "Cancel")); + assert!(heading.bottom() <= cancel.top()); + assert!(cancel.bottom() <= at(&saving, "Width").top(), "Cancel and Save are above the rows"); + assert_eq!(at(&saving, "Width").top(), width, "the rows stay where they were"); + let used = at(&saving, "More Settings").bottom() - heading.top(); assert!(used <= super::HEIGHT, "the column used {used}"); } @@ -957,7 +1047,7 @@ mod tests { let out = frame(&mut app); assert!(!out.shapes.is_empty()); } - // More Settings shares the fields; Cancel goes back. + // More Settings edits the same fields. app.ui.dialog.as_mut().unwrap().kind = super::MORE.into(); set(&mut app, "artboards", json!(3)); frame(&mut app); @@ -977,48 +1067,380 @@ mod tests { assert_eq!(app.session.prefs.recent_new_docs[0].name, "Zine"); } - /// Save Preset works in More Settings too (#4893), saving its settings: the artboard layout - /// only More Settings shows among them. - #[test] - fn more_settings_saves_a_preset_of_its_settings() { + type Harness = egui_kittest::Harness<'static, VectorcraftApp>; + + /// New Document open in a harness of `size`, with the app's key handling and the dialogs drawn. + fn harness(size: egui::Vec2) -> Harness { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.newDialog", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().kind = super::MORE.into(); - set(&mut app, "artboards", json!(4)); - set(&mut app, "artboardLayout", json!({"layout": "column", "spacing": 30})); - set(&mut app, super::SAVING, json!(true)); - set(&mut app, "presetName", json!("Strip")); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut time = 0.0; - let mut run = |app: &mut VectorcraftApp, events| { - time += 0.5; - let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0))); - let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); - out.textures_delta.clear(); - out - }; - run(&mut app, vec![]); - let out = run(&mut app, vec![]); - fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { - match s { - egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), - egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), - _ => {} + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + crate::shortcuts::handle(app, ui.ctx()); + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(5); + h + } + + fn big() -> Harness { + harness(egui::vec2(1400.0, 1000.0)) + } + + /// Open a pop-up as a control client would, with `artboards` set, and let it draw. + fn open_more(h: &mut Harness, artboards: u64) { + set(h.state_mut(), "artboards", json!(artboards)); + h.state_mut().ui.dialog.as_mut().unwrap().kind = super::MORE.into(); + h.run_steps(4); + } + + fn kind(h: &Harness) -> String { + h.state().ui.dialog.as_ref().unwrap().kind.clone() + } + + /// A primary click at `at`, as a pointer makes it. + fn click_at(h: &mut Harness, at: egui::Pos2) { + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + } + + /// Press Save Preset, leaving the preset's name focused. + fn start_saving(h: &mut Harness) { + h.get_by_label("Save Preset").click(); + h.run_steps(4); + } + + /// Replace the text of field `label` with `text` and press Enter. + fn enter_in(h: &mut Harness, label: &str, text: &str) { + let all = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + h.get_by_role_and_label(Role::TextInput, label).focus(); + h.run_steps(2); + h.key_press_modifiers(all, egui::Key::A); + h.get_by_role_and_label(Role::TextInput, label).type_text(text); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + } + + /// More Settings opens over New Document with only what Preset Details lacks, its first field + /// takes the keyboard focus, and Done leaves New Document open with what the pop-up set. + #[test] + fn more_settings_is_a_popup_holding_only_the_artboard_layout() { + let mut h = big(); + h.get_by_label("More Settings").click(); + h.run_steps(4); + assert_eq!(kind(&h), super::MORE); + assert!(h.get_by_role_and_label(Role::TextInput, "Number of Artboards:").is_focused(), "the pop-up's first field has the focus"); + for shown in ["Number of Artboards:", "Spacing:", "Columns:", "Create", "Close"] { + let _ = h.get_by_label(shown); + } + for gone in [ + "Name:", + "Profile:", + "Size:", + "Width:", + "Height:", + "Units:", + "Orientation:", + "Bleed:", + "Background Contents:", + "Create Document", + "Templates…", + ] { + assert!(h.query_by_label(gone).is_none(), "{gone} is in the pop-up"); + } + h.get_by_label("Done").click(); + h.run_steps(3); + assert_eq!(kind(&h), super::KIND, "Done goes back to New Document"); + assert!(h.query_by_label("Spacing:").is_none()); + assert!(h.state().session.documents().is_empty()); + } + + /// Escape, a click outside and `ui.dialog.cancel` each close only the pop-up, and the fields it + /// edited stay set. Escape on New Document itself then closes the dialog. + #[test] + fn a_popup_closes_on_its_own_and_keeps_the_values() { + let mut h = big(); + open_more(&mut h, 4); + h.get_by_label(super::ArtboardLayout::Column.label()).click(); + h.run_steps(3); + assert_eq!(kind(&h), super::MORE, "a click inside keeps it open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(kind(&h), super::KIND, "Escape closes only the pop-up"); + assert_eq!(field(h.state(), "artboardLayout")["layout"], json!("column")); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none(), "the next Escape closes New Document"); + + let mut h = big(); + open_more(&mut h, 4); + let outside = h.get_by_label("PRESET DETAILS").rect().center(); + click_at(&mut h, outside); + assert_eq!(kind(&h), super::KIND, "a click outside closes the pop-up"); + assert_eq!(field(h.state(), "artboards"), json!(4)); + + open_more(&mut h, 4); + crate::dialogs::cancel(h.state_mut()); + assert_eq!(kind(&h), super::KIND, "ui.dialog.cancel closes only the pop-up"); + set(h.state_mut(), super::SAVING, json!(true)); + crate::dialogs::cancel(h.state_mut()); + assert_eq!((kind(&h), field(h.state(), super::SAVING)), (super::KIND.to_string(), json!(false)), "and Save Preset's"); + crate::dialogs::cancel(h.state_mut()); + assert!(h.state().ui.dialog.is_none(), "with no pop-up it cancels New Document"); + } + + /// A click that lands on Create, Close, a preset card or a category tab while the pop-up is open + /// only closes the pop-up: New Document stays open with the values the pop-up set. + #[test] + fn a_click_outside_the_popup_does_not_reach_the_dialog() { + let mut reached = vec![]; + for target in ["Create", "Close", "card", "Web"] { + let mut h = big(); + open_more(&mut h, 4); + let (category, preset) = (field(h.state(), "category"), field(h.state(), "preset")); + let at = if target == "card" { + // The Print tab's second card: the first is the chosen one. + let tab = h.get_by_label("Print").rect(); + egui::pos2(tab.left() + 146.0 + 12.0 + 73.0, tab.bottom() + 120.0) + } else { + h.get_by_label(target).rect().center() + }; + click_at(&mut h, at); + let kept = h.state().ui.dialog.is_some() + && kind(&h) == super::KIND + && h.state().session.documents().is_empty() + && field(h.state(), "artboards") == json!(4) + && (field(h.state(), "category"), field(h.state(), "preset")) == (category, preset); + if !kept { + reached.push(target); } } - let mut texts = vec![]; - out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); - assert!(texts.iter().any(|(t, _)| t == "Preset Name"), "the preset's name field shows"); - let at = texts.iter().find(|(t, _)| t == "Save").map(|(_, p)| *p).expect("a Save button"); - let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - run(&mut app, vec![egui::Event::PointerMoved(at), press(true)]); - run(&mut app, vec![press(false)]); - let saved = app.session.prefs.new_doc_presets.iter().find(|p| p.name == "Strip").expect("saved"); + assert!(reached.is_empty(), "the click reached {reached:?}"); + } + + /// Enter applies the field and closes the pop-up. It does not create the document. + #[test] + fn enter_in_a_popup_does_not_create_the_document() { + let mut h = big(); + open_more(&mut h, 3); + enter_in(&mut h, "Spacing:", "25"); + assert_eq!(kind(&h), super::KIND, "Enter closes the pop-up"); + assert_eq!(field(h.state(), "artboardLayout")["spacing"], json!(25.0)); + assert!(h.state().session.documents().is_empty(), "no document was made"); + } + + /// One artboard disables the layout, Spacing and Columns; Columns stays within the artboards + /// and Spacing is not negative. + #[test] + fn the_popup_keeps_the_layout_rules() { + let mut h = big(); + open_more(&mut h, 1); + for name in [super::ArtboardLayout::Row.label(), "Change to Right-to-Left Layout"] { + assert!(h.get_by_label(name).accesskit_node().is_disabled(), "{name}"); + } + for name in ["Spacing:", "Columns:"] { + assert!(h.get_by_role_and_label(Role::TextInput, name).accesskit_node().is_disabled(), "{name}"); + } + open_more(&mut h, 3); + assert!(!h.get_by_role_and_label(Role::TextInput, "Spacing:").accesskit_node().is_disabled()); + enter_in(&mut h, "Columns:", "99"); + assert_eq!(field(h.state(), "artboardLayout")["columns"], json!(3)); + open_more(&mut h, 3); + enter_in(&mut h, "Spacing:", "-5"); + assert_eq!(field(h.state(), "artboardLayout")["spacing"], json!(0.0)); + } + + /// Saving swaps the top of Preset Details for the heading, the preset's name, Cancel and Save, and + /// hides Close and Create; the rows under it stay editable, and Enter in one of them neither saves + /// nor creates. + #[test] + fn save_preset_is_a_mode_of_the_panel() { + let mut h = big(); + start_saving(&mut h); + let _ = (h.get_by_label("Save Document Preset"), h.get_by_label("Cancel"), h.get_by_label("Save")); + for gone in ["PRESET DETAILS", "Create", "Close"] { + assert!(h.query_by_label(gone).is_none(), "{gone} shows while saving"); + } + assert!(h.query_by_role_and_label(Role::TextInput, "Document Name").is_none(), "the document's name is hidden"); + let _ = (h.get_by_label("Advanced Options"), h.get_by_label("More Settings")); + enter_in(&mut h, "Artboards", "2"); + assert_eq!(field(h.state(), "artboards"), json!(2), "the rows stay editable"); + assert_eq!(field(h.state(), super::SAVING), json!(true), "Enter in a row does not save"); + assert!(h.state().session.documents().is_empty() && h.state().session.prefs.new_doc_presets.is_empty()); + h.get_by_label("More Settings").click(); + h.run_steps(4); + assert_eq!(kind(&h), super::MORE, "More Settings opens while saving"); + } + + /// Save Preset focuses the default name with its text selected, Enter in it is Save, and the + /// saved preset keeps the fields. + #[test] + fn enter_in_the_preset_name_saves() { + let mut h = big(); + set(h.state_mut(), "artboards", json!(3)); + h.run_steps(2); + start_saving(&mut h); + assert_eq!(field(h.state(), "presetName"), json!("Preset 1")); + assert!(h.get_by_role_and_label(Role::TextInput, "Preset Name").is_focused()); + h.event(egui::Event::Text("Trio".into())); + h.run_steps(2); + assert_eq!(field(h.state(), "presetName"), json!("Trio"), "typing replaced the selected text"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let saved = h.state().session.prefs.new_doc_presets.iter().find(|p| p.name == "Trio").expect("Enter saved it"); + assert_eq!(saved.artboards, 3); + assert_eq!((field(h.state(), super::SAVING), field(h.state(), "artboards")), (json!(false), json!(3)), "the fields stay"); + assert_eq!(field(h.state(), "name"), json!("Untitled-1")); + assert!(h.state().session.documents().is_empty() && h.state().ui.dialog.is_some()); + } + + /// Save Preset's default name is the first "Preset N" no saved preset has, after a save as well. + #[test] + fn the_next_save_offers_a_new_name() { + let mut h = big(); + start_saving(&mut h); + assert_eq!(field(h.state(), "presetName"), json!("Preset 1")); + h.get_by_label("Save").click(); + h.run_steps(3); + start_saving(&mut h); + assert_eq!(field(h.state(), "presetName"), json!("Preset 2"), "not the saved preset's name"); + set(h.state_mut(), "presetName", json!("preset 3")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + start_saving(&mut h); + assert_eq!(field(h.state(), "presetName"), json!("Preset 2"), "not a name a saved preset has"); + } + + /// A New Document that opens already saving (as a control client can set it) fills in and focuses + /// the default name, also after the last one was closed while saving. + #[test] + fn a_dialog_that_opens_saving_fills_in_the_name() { + let mut h = big(); + start_saving(&mut h); + h.state_mut().ui.dialog = None; + h.run_steps(2); + h.state_mut().run("file.newDialog", json!({})).unwrap(); + set(h.state_mut(), super::SAVING, json!(true)); + h.run_steps(4); + assert_eq!(field(h.state(), "presetName"), json!("Preset 1")); + assert!(h.get_by_role_and_label(Role::TextInput, "Preset Name").is_focused()); + } + + /// Enter that is still held after Enter saved a preset does not create the document. + #[test] + fn a_held_enter_after_a_save_does_not_create() { + let mut h = big(); + start_saving(&mut h); + let enter = |repeat| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat, modifiers: Default::default() }; + // Pressed and not released: the next presses are the key's repeats. + h.event(enter(false)); + h.run_steps(1); + assert_eq!(field(h.state(), super::SAVING), json!(false), "Enter saved"); + for _ in 0..3 { + h.event(enter(true)); + h.run_steps(1); + } + assert!(h.state().session.documents().is_empty() && h.state().ui.dialog.is_some()); + } + + /// Escape in the mode is Cancel: it saves nothing, keeps edits made to the fields, and New Document + /// stays open. The Cancel button does the same. + #[test] + fn escape_and_cancel_leave_saving_and_keep_the_edits() { + let mut h = big(); + start_saving(&mut h); + set(h.state_mut(), "artboards", json!(5)); + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!((field(h.state(), super::SAVING), field(h.state(), "artboards")), (json!(false), json!(5)), "Escape keeps the edits"); + assert!(h.state().session.prefs.new_doc_presets.is_empty(), "and saves nothing"); + assert!(h.state().ui.dialog.is_some(), "and New Document stays open"); + + start_saving(&mut h); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(field(h.state(), super::SAVING), json!(false)); + let _ = h.get_by_label("Create"); + } + + /// `ui.dialog.confirm` while saving is Save: it stores the preset and creates no document. + #[test] + fn confirm_while_saving_does_not_create_a_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + frame(&mut app); + set(&mut app, super::SAVING, json!(true)); + set(&mut app, "presetName", json!("Zed")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.session.documents().is_empty() && app.session.prefs.new_doc_presets.iter().any(|p| p.name == "Zed")); + assert_eq!((field(&app, super::SAVING), field(&app, "category")), (json!(false), json!("Saved"))); + set(&mut app, super::SAVING, json!(true)); + set(&mut app, "presetName", json!("")); + assert!(crate::dialogs::confirm(&mut app).is_err(), "an empty name is refused"); + assert_eq!(field(&app, super::SAVING), json!(true)); + assert!(app.session.documents().is_empty()); + } + + /// The preset keeps the artboard layout, spacing and columns set in More Settings' pop-up + /// (#4893), and New Document stays open on it. An empty name stays in the mode. + #[test] + fn save_preset_saves_what_more_settings_set() { + let mut h = big(); + open_more(&mut h, 4); + enter_in(&mut h, "Spacing:", "30"); + open_more(&mut h, 4); + h.get_by_label(super::ArtboardLayout::Column.label()).click(); + h.run_steps(3); + h.get_by_label("Change to Right-to-Left Layout").click(); + h.run_steps(3); + h.get_by_label("Done").click(); + h.run_steps(3); + h.get_by_label("Save Preset").click(); + h.run_steps(4); + assert!(field(h.state(), super::SAVING) == json!(true) && h.get_by_role_and_label(Role::TextInput, "Preset Name").is_focused()); + let _ = (h.get_by_label("Save"), h.get_by_label("Cancel")); + set(h.state_mut(), "presetName", json!(" ")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + assert_eq!(field(h.state(), super::SAVING), json!(true), "an empty name stays in the mode"); + set(h.state_mut(), "presetName", json!("Strip")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + let saved = h.state().session.prefs.new_doc_presets.iter().find(|p| p.name == "Strip").expect("saved"); assert_eq!(saved.artboards, 4); + assert!(saved.right_to_left); assert_eq!((saved.layout, saved.spacing), (super::ArtboardLayout::Column, 30.0)); - assert_eq!((field(&app, "category"), field(&app, "preset"), field(&app, super::SAVING)), (json!("Saved"), json!("Strip"), json!(false))); - assert_eq!(app.ui.dialog.as_ref().unwrap().kind, super::MORE, "More Settings stays open"); + assert_eq!( + (field(h.state(), "category"), field(h.state(), "preset"), field(h.state(), super::SAVING)), + (json!("Saved"), json!("Strip"), json!(false)) + ); + assert_eq!(kind(&h), super::KIND, "New Document stays open"); + assert!(h.state().session.documents().is_empty()); + } + + /// In a small window the pop-up stays on screen, and its button stays in view under rows that scroll. + #[test] + fn the_popup_stays_inside_a_small_window() { + let mut h = harness(egui::vec2(480.0, 260.0)); + open_more(&mut h, 3); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 260.0)); + for label in ["Number of Artboards:", "Done"] { + assert!(screen.contains_rect(h.get_by_label(label).rect()), "{label} is off screen"); + } } } diff --git a/crates/ui-egui/src/dialogs/tests_accessibility.rs b/crates/ui-egui/src/dialogs/tests_accessibility.rs index 8525fd8bb..d3ef1ce8c 100644 --- a/crates/ui-egui/src/dialogs/tests_accessibility.rs +++ b/crates/ui-egui/src/dialogs/tests_accessibility.rs @@ -86,9 +86,8 @@ fn document_setup_names_each_bleed_side() { fn new_document_names_its_fields_by_their_captions() { let mut app = app(); app.run("file.newDialog", json!({})).unwrap(); - app.ui.dialog.as_mut().expect("New Document is open").fields.insert("__saving".into(), json!(true)); let h = strict(app); - for name in ["Document Name", "Preset Name", "Width", "Height", "Artboards"] { + for name in ["Document Name", "Width", "Height", "Artboards"] { let _ = h.get_by_role_and_label(Role::TextInput, name); } for name in ["Units", "Background Contents"] { @@ -96,7 +95,7 @@ fn new_document_names_its_fields_by_their_captions() { } } -/// New Document's More Settings, with Save Preset open, leaves no field unnamed. +/// New Document's More Settings pop-up and Save Preset mode leave no field unnamed. #[test] fn new_document_more_settings_names_its_fields() { let mut app = app(); @@ -104,8 +103,11 @@ fn new_document_more_settings_names_its_fields() { let d = app.ui.dialog.as_mut().expect("New Document is open"); d.kind = super::new_document::MORE.into(); d.fields.insert("__saving".into(), json!(true)); + d.fields.insert("artboards".into(), json!(3)); let h = strict(app); - let _ = h.get_by_role_and_label(Role::TextInput, "Preset Name"); + for name in ["Preset Name", "Number of Artboards:", "Spacing:", "Columns:"] { + let _ = h.get_by_role_and_label(Role::TextInput, name); + } } /// Split Into Grid keeps translated visible labels while naming row and column gutter and total diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b7006..50868dca6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -284,7 +284,6 @@ font-technology Variable Променливи Update While Dragging Обновяване при плъзгане Hide Video Rulers Скриване на видеолинийките Background Contents Съдържание на фона - Background Contents: Съдържание на фона: Background: Фон: Backspace Backspace Bar Graph Tool Инструмент „Стълбчеста диаграма“ @@ -654,7 +653,6 @@ font-technology Variable Променливи Couldn't {} {name}: {e} Неуспешно {name} на {}: {e} Create Създаване Create Clipping Mask Създаване на маска за изрязване - Create Document Създаване на документ Create from Guides Създаване от водачи Create from Selection Създаване от селекцията Create From: Създаване от: @@ -2074,7 +2072,6 @@ font-technology Variable Променливи New Color Group… Нова цветова група… New Document Presets Предварителни настройки за нов документ New file Нов файл - New from Template Нов от шаблон New from Template… Нов от шаблон… New Graphic Style Нов графичен стил New Graphic Style (Alt-click to name it) Нов графичен стил (кликнете с Alt, за да го наименувате) @@ -2858,6 +2855,7 @@ digits Malayalam Малаялам Save PDF Preset Запази PDF предварителна настройка Save Perspective Grid Preset Запази предварителна настройка на перспективната мрежа Save Preset Запази предварителна настройка + Save Document Preset Запази предварителна настройка на документа Save Preset… Запази предварителна настройка… Save Print Preset Запази предварителна настройка за печат Save Recovery Data Запази данни за възстановяване @@ -3422,7 +3420,6 @@ digits Malayalam Малаялам Template Шаблон Templates Шаблони Templates Folder Папка с шаблони - Templates… Шаблони… Text Displayed in Cell: Текст, показван в клетката: Text Export Options Опции за експортиране на текст Text Fill & Stroke Color Цвят на запълване и щрих на текста diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a655..55af09bc9 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2531,15 +2531,12 @@ digits Malayalam Malajalam Selected Swatches Odabrani uzorci boja Advanced Options Napredne opcije Background Contents Sadržaj pozadine - Background Contents: Sadržaj pozadine: BLANK DOCUMENT PRESETS PREDLOŠCI PRAZNIH DOKUMENATA Bleed Napust Change to Right-to-Left Layout Prebaci na raspored s desna nalijevo Columns: Kolone: - Create Document Kreiraj dokument Documents you create appear here. Dokumenti koje kreirate pojavljuju se ovdje. More Settings Više postavki - New from Template Novo iz predloška Number of Artboards: Broj radnih ploča: Orientation: Orijentacija: PRESET DETAILS DETALJI PREDLOŠKA @@ -2550,7 +2547,6 @@ digits Malayalam Malajalam RECENT NEDAVNO RECENT (1) NEDAVNO (1) SAVED SAČUVANO - Templates… Predlošci… Transparent (Default) Prozirno (zadano) Save for Office Documents Sačuvaj za Office dokumente Transparent Background Prozirna pozadina @@ -3486,6 +3482,7 @@ theme Appearance Izgled Arrange by Row Rasporedi po redu Arrange by Column Rasporedi po koloni Save Preset Sačuvaj predložak + Save Document Preset Sačuvaj predložak dokumenta Screen (72 ppi) Ekran (72 ppi) Social Square Post 1080×1080 Kvadratna objava za društvene mreže 1080×1080 Distribute Rasporedi ravnomjerno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f..a37133f14 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -423,7 +423,6 @@ antiAlias Sharp Nítid Back Enrere Background Color: Color de fons: Background Contents Contingut del fons - Background Contents: Contingut del fons: Background Level Nivell de fons Background: Fons: Backspace Retrocés @@ -917,7 +916,6 @@ antiAlias Sharp Nítid Create Clipping Mask Crea una màscara de retall Create Compound Path From Ellipses Crea un traçat compost a partir d’el·lipses Create Dimension Crea una dimensió - Create Document Crea un document Create from Guides Crea a partir de guies Create from Selection Crea a partir de la selecció Create From: Crea a partir de: @@ -2597,7 +2595,6 @@ digits Malayalam Malaiàlam New Design Disseny nou New Document Presets Preajustos de document nous New file Fitxer nou - New from Template Nou a partir d’una plantilla New from Template… Nou a partir d’una plantilla… New Graphic Style Estil gràfic nou New Graphic Style (Alt-click to name it) Estil gràfic nou (Alt+clic per posar-hi nom) @@ -3643,6 +3640,7 @@ digits Malayalam Malaiàlam Save PDF Preset Desa el preajust de PDF Save Perspective Grid Preset Desa el preajust de quadrícula en perspectiva Save Preset Desa el preajust + Save Document Preset Desa el preajust de document Save Preset… Desa el preajust… Save Print Preset Desa el preajust d’impressió Save Recovery Data Desa les dades de recuperació @@ -4369,7 +4367,6 @@ digits Malayalam Malaiàlam Template literal not closed El literal de plantilla no està tancat Templates Plantilles Templates Folder Carpeta de plantilles - Templates… Plantilles… Tetrad Tètrada Tetrad 2 Tètrada 2 Tetrad 3 Tètrada 3 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2..ece95b1cb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2562,15 +2562,12 @@ digits Malayalam Malajálamské Selected Swatches Vybrané položky vzorníku Advanced Options Rozšířené možnosti Background Contents Obsah pozadí - Background Contents: Obsah pozadí: BLANK DOCUMENT PRESETS PŘEDNASTAVENÍ PRÁZDNÝCH DOKUMENTŮ Bleed Spadávka Change to Right-to-Left Layout Změnit na rozvržení zprava doleva Columns: Sloupce: - Create Document Vytvořit dokument Documents you create appear here. Zde se zobrazí dokumenty, které vytvoříte. More Settings Další nastavení - New from Template Nový ze šablony Number of Artboards: Počet pracovních ploch: Orientation: Orientace: PRESET DETAILS PODROBNOSTI PŘEDNASTAVENÍ @@ -2581,7 +2578,6 @@ digits Malayalam Malajálamské RECENT NEDÁVNÉ RECENT (1) NEDÁVNÉ (1) SAVED ULOŽENÉ - Templates… Šablony… Transparent (Default) Průhledné (výchozí) Save for Office Documents Uložit pro kancelářské dokumenty Transparent Background Průhledné pozadí @@ -3536,6 +3532,7 @@ theme Appearance Motiv Arrange by Row Uspořádat po řádcích Arrange by Column Uspořádat po sloupcích Save Preset Uložit přednastavení + Save Document Preset Uložit přednastavení dokumentu Screen (72 ppi) Obrazovka (72 ppi) Social Square Post 1080×1080 Čtvercový příspěvek pro sociální sítě 1080×1080 Distribute Rozmístit diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718b..7bc024666 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2350,15 +2350,12 @@ digits Malayalam Malayalam Selected Swatches Markerede farveprøver Advanced Options Avancerede indstillinger Background Contents Baggrundens indhold - Background Contents: Baggrundens indhold: BLANK DOCUMENT PRESETS TOMME DOKUMENTFORUDINDSTILLINGER Bleed Beskæring Change to Right-to-Left Layout Skift til højre-til-venstre-layout Columns: Kolonner: - Create Document Opret dokument Documents you create appear here. Dokumenter, du opretter, vises her. More Settings Flere indstillinger - New from Template Nyt fra skabelon Number of Artboards: Antal tegnebrætter: Orientation: Orientering: PRESET DETAILS FORUDINDSTILLINGSDETALJER @@ -2369,7 +2366,6 @@ digits Malayalam Malayalam RECENT SENESTE RECENT (1) SENESTE (1) SAVED GEMT - Templates… Skabeloner… Transparent (Default) Gennemsigtig (standard) Save for Office Documents Gem til Office-dokumenter Transparent Background Gennemsigtig baggrund @@ -3273,6 +3269,7 @@ theme Appearance Udseende Arrange by Row Arranger efter række Arrange by Column Arranger efter kolonne Save Preset Gem forudindstilling + Save Document Preset Gem dokumentforudindstilling Screen (72 ppi) Skærm (72 ppi) Social Square Post 1080×1080 Socialt firkantet opslag 1080×1080 Distribute Fordel diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238..60ac922fa 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -272,7 +272,6 @@ font-technology Variable Variabel Update While Dragging Beim Ziehen aktualisieren Hide Video Rulers Videolineale ausblenden Background Contents Hintergrundinhalt - Background Contents: Hintergrundinhalt: Background: Hintergrund: Backspace Rücktaste Bar Graph Tool Balkendiagramm-Werkzeug @@ -647,7 +646,6 @@ font-technology Variable Variabel Couldn't {} {name}: {e} Fehler bei „{}“ für {name}: {e} Create Erstellen Create Clipping Mask Schnittmaske erstellen - Create Document Dokument erstellen Create from Guides Aus Hilfslinien erstellen Create from Selection Aus Auswahl erstellen Create From: Erstellen aus: @@ -2099,7 +2097,6 @@ file-filter Templates Vorlagen New Color Group… Neue Farbgruppe… New Document Presets Vorgaben für neue Dokumente New file Neue Datei - New from Template Neu aus Vorlage New from Template… Neu aus Vorlage… New Graphic Style Neuer Grafikstil New Graphic Style (Alt-click to name it) Neuer Grafikstil (Alt-Klick, um ihn zu benennen) @@ -2891,6 +2888,7 @@ digits Malayalam Malayalam Save PDF Preset PDF-Vorgabe speichern Save Perspective Grid Preset Vorgabe für Perspektivenraster speichern Save Preset Vorgabe speichern + Save Document Preset Dokumentvorgabe speichern Preset {number} Vorgabe {number} Save Preset… Vorgabe speichern… Save Print Preset Druckvorgabe speichern @@ -3454,7 +3452,6 @@ effect Points Punkte Template Vorlage Templates Vorlagen Templates Folder Vorlagenordner - Templates… Vorlagen… Text Displayed in Cell: In der Zelle angezeigter Text: Text Export Options Textexportoptionen Text Fill & Stroke Color Flächen- und Konturfarbe des Texts diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6e..2abc58dee 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -275,7 +275,6 @@ font-technology Variable Μεταβλητές Update While Dragging Ενημέρωση κατά τη μεταφορά Hide Video Rulers Απόκρυψη χαράκων βίντεο Background Contents Περιεχόμενα φόντου - Background Contents: Περιεχόμενο φόντου: Background: Φόντο: Backspace Backspace Bar Graph Tool Ραβδόγραμμα (Bar Graph Tool) @@ -628,7 +627,6 @@ font-technology Variable Μεταβλητές Couldn't {} {name}: {e} Δεν ήταν δυνατή η {} του {name}: {e} Create Δημιουργία Create Clipping Mask Δημιουργία μάσκας αποκοπής - Create Document Δημιουργία εγγράφου Create from Guides Δημιουργία από οδηγούς Create from Selection Δημιουργία από επιλογή Create From: Δημιουργία από: @@ -2074,7 +2072,6 @@ font-technology Variable Μεταβλητές Temperature Θερμοκρασία Template Πρότυπο Templates Πρότυπα - Templates… Πρότυπα… Threshold Κατώφλι Tile Παράθεση Tint Χρωματική απόχρωση @@ -2902,7 +2899,6 @@ theme Appearance Εμφάνιση New Color Group… Νέα ομάδα χρωμάτων… New Document Presets Νέες προεπιλογές εγγράφου New file Νέο αρχείο - New from Template Νέο από πρότυπο New from Template… Νέο από πρότυπο… New Graphic Style Νέο στυλ γραφικών New Graphic Style (Alt-click to name it) Νέο στυλ γραφικών (Alt-κλικ για ονομασία) @@ -3466,6 +3462,7 @@ digits Malayalam Μαλαγιάλαμ Save PDF Preset Αποθήκευση προεπιλογής PDF Save Perspective Grid Preset Αποθήκευση προεπιλογής πλέγματος προοπτικής Save Preset Αποθήκευση προεπιλογής + Save Document Preset Αποθήκευση προεπιλογής εγγράφου Save Print Preset Αποθήκευση προεπιλογής εκτύπωσης Save Recovery Data Αποθήκευση δεδομένων ανάκτησης Save Save for Web Preset Αποθήκευση προεπιλογής για ιστό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d5059411..f8ce0608a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -297,7 +297,6 @@ font-technology Variable Variables Update While Dragging Actualizar al arrastrar Hide Video Rulers Ocultar reglas de video Background Contents Contenido de fondo - Background Contents: Contenido de fondo: Background: Fondo: Backspace Retroceso Bar Graph Tool Herramienta Gráfico de barras @@ -669,7 +668,6 @@ font-technology Variable Variables Couldn't {} {name}: {e} No se pudo {} {name}: {e} Create Crear Create Clipping Mask Crear máscara de recorte - Create Document Crear documento Create from Guides Crear a partir de guías Create from Selection Crear a partir de la selección Create From: Crear a partir de: @@ -2146,7 +2144,6 @@ font-technology Variable Variables New Color Group… Nuevo grupo de colores… New Document Presets Preajustes de documento nuevo New file Archivo nuevo - New from Template Nuevo a partir de plantilla New from Template… Nuevo a partir de plantilla… New Graphic Style Nuevo estilo gráfico New Graphic Style (Alt-click to name it) Nuevo estilo gráfico (Alt+clic para darle nombre) @@ -2933,6 +2930,7 @@ digits Malayalam Malayálam Save PDF Preset Guardar preajuste de PDF Save Perspective Grid Preset Guardar preajuste de cuadrícula de perspectiva Save Preset Guardar preajuste + Save Document Preset Guardar preajuste de documento Save Preset… Guardar preajuste… Save Print Preset Guardar preajuste de impresión Save Recovery Data Guardar datos de recuperación @@ -3510,7 +3508,6 @@ digits Malayalam Malayálam Template Plantilla Templates Plantillas Templates Folder Carpeta de plantillas - Templates… Plantillas… Text Displayed in Cell: Texto mostrado en la celda: Text Export Options Opciones de exportación de texto Text Fill & Stroke Color Color de relleno y trazo del texto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111..e8e3b33fd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2506,15 +2506,12 @@ digits Malayalam Malajalam Selected Swatches Valitut värimallit Advanced Options Lisäasetukset Background Contents Taustan sisältö - Background Contents: Taustan sisältö: BLANK DOCUMENT PRESETS TYHJÄN ASIAKIRJAN ESIASETUKSET Bleed Leikkuuvara Change to Right-to-Left Layout Vaihda oikealta vasemmalle -asetteluun Columns: Sarakkeet: - Create Document Luo asiakirja Documents you create appear here. Luomasi asiakirjat näkyvät tässä. More Settings Lisää asetuksia - New from Template Uusi mallista Number of Artboards: Piirtoalustojen määrä: Orientation: Suunta: PRESET DETAILS ESIASETUKSEN TIEDOT @@ -2525,7 +2522,6 @@ digits Malayalam Malajalam RECENT VIIMEISIMMÄT RECENT (1) VIIMEISIMMÄT (1) SAVED TALLENNETUT - Templates… Mallit… Transparent (Default) Läpinäkyvä (oletus) Save for Office Documents Tallenna Office-asiakirjoja varten Transparent Background Läpinäkyvä tausta @@ -3474,6 +3470,7 @@ theme Appearance Ulkoasu Arrange by Row Järjestä riveittäin Arrange by Column Järjestä sarakkeittain Save Preset Tallenna esiasetus + Save Document Preset Tallenna asiakirjan esiasetus Screen (72 ppi) Näyttö (72 ppi) Social Square Post 1080×1080 Neliömäinen somejulkaisu 1080×1080 Distribute Jaa tasaisesti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e..4cafac1f6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -298,7 +298,6 @@ font-technology Variable Variables Update While Dragging Mettre à jour pendant le déplacement Hide Video Rulers Masquer les règles vidéo Background Contents Contenu de l’arrière-plan - Background Contents: Contenu de l’arrière-plan : Background: Arrière-plan : Backspace Retour arrière Bar Graph Tool Outil Graphe à barres @@ -670,7 +669,6 @@ font-technology Variable Variables Couldn't {} {name}: {e} Impossible de {} {name} : {e} Create Créer Create Clipping Mask Créer un masque d’écrêtage - Create Document Créer un document Create from Guides Créer à partir des repères Create from Selection Créer à partir de la sélection Create From: Créer à partir de : @@ -2138,7 +2136,6 @@ font-technology Variable Variables New Color Group… Nouveau groupe de couleurs… New Document Presets Paramètres prédéfinis de nouveau document New file Nouveau fichier - New from Template Nouveau d’après un modèle New from Template… Nouveau d’après un modèle… New Graphic Style Nouveau style graphique New Graphic Style (Alt-click to name it) Nouveau style graphique (Alt+clic pour le nommer) @@ -2925,6 +2922,7 @@ digits Malayalam Malayalam Save PDF Preset Enregistrer le paramètre prédéfini PDF Save Perspective Grid Preset Enregistrer le paramètre prédéfini de grille de perspective Save Preset Enregistrer le paramètre prédéfini + Save Document Preset Enregistrer le paramètre prédéfini du document Save Preset… Enregistrer le paramètre prédéfini… Save Print Preset Enregistrer le paramètre prédéfini d’impression Save Recovery Data Enregistrer les données de récupération @@ -3502,7 +3500,6 @@ digits Malayalam Malayalam Template Modèle Templates Modèles Templates Folder Dossier des modèles - Templates… Modèles… Text Displayed in Cell: Texte affiché dans la cellule : Text Export Options Options d’exportation du texte Text Fill & Stroke Color Couleur de fond et de contour du texte diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03dec..9cbd1121a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -491,7 +491,6 @@ antiAlias Sharp शार्प Back वापस Background Color: बैकग्राउंड रंग: Background Contents बैकग्राउंड सामग्री - Background Contents: बैकग्राउंड सामग्री: Background Level बैकग्राउंड स्तर Background: बैकग्राउंड: Backspace Backspace @@ -1029,7 +1028,6 @@ antiAlias Sharp शार्प Create Clipping Mask क्लिपिंग मास्क बनाएँ Create Compound Path From Ellipses एलिप्स से कंपाउंड पाथ बनाएँ Create Dimension आयाम बनाएँ - Create Document डॉक्यूमेंट बनाएं Create from Guides गाइड से बनाएँ Create from Selection सिलेक्शन से बनाएँ Create From: से बनाएँ: @@ -2836,7 +2834,6 @@ antiAlias Sharp शार्प New Design नई डिजाइन New Document Presets नया डॉक्यूमेंट प्रीसेट New file नई फ़ाइल - New from Template टेम्पलेट से नया New from Template… टेम्पलेट से नया… New Graphic Style नई ग्राफिक स्टाइल New Graphic Style (Alt-click to name it) नया ग्राफ़िक स्टाइल (नाम देने के लिए Alt-क्लिक करें) @@ -3957,6 +3954,7 @@ antiAlias Sharp शार्प Save PDF Preset PDF प्रीसेट सेव करें Save Perspective Grid Preset सेव पर्सपेक्टिव ग्रिड प्रीसेट Save Preset प्रीसेट सेव करें + Save Document Preset डॉक्यूमेंट प्रीसेट सेव करें Save Preset… प्रीसेट सेव करें… Save Print Preset सेव करें प्रिंट प्रीसेट Save Recovery Data रिकवरी डेटा सेव करें @@ -4736,7 +4734,6 @@ antiAlias Sharp शार्प Template literal not closed टेम्पलेट साक्षर नहीं बंद Templates टेम्पलेट्स Templates Folder टेम्पलेट्स फ़ोल्डर - Templates… टेम्पलेट्स… Tetrad टेट्राड Tetrad 2 टेट्राड 2 Tetrad 3 टेट्राड 3 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37..601bfac2e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -393,7 +393,6 @@ font-technology Variable Változó Update While Dragging Frissítés húzás közben Hide Video Rulers Videovonalzók elrejtése Background Contents Háttértartalom - Background Contents: Háttértartalom: Background: Háttér: Backspace Backspace Balance Egyensúly @@ -864,7 +863,6 @@ font-technology Variable Változó Craquelure… Repedezettség… Create Létrehozás Create Clipping Mask Vágómaszk létrehozása - Create Document Dokumentum létrehozása Create from Guides Létrehozás segédvonalakból Create from Selection Létrehozás a kijelölésből Create From: Létrehozás ebből: @@ -2598,7 +2596,6 @@ file-filter Templates Sablonok New Data Set… Új adatkészlet… New Document Presets Új dokumentum előbeállításai New file Új fájl - New from Template Új sablonból New from Template… Új sablonból… New Graphic Style Új grafikai stílus New Graphic Style (Alt-click to name it) Új grafikai stílus (Alt+kattintás: elnevezéssel) @@ -3516,6 +3513,7 @@ mark-style Japanese Japán Save PDF Preset PDF-előbeállítás mentése Save Perspective Grid Preset Perspektivikus rács előbeállításának mentése Save Preset Előbeállítás mentése + Save Document Preset Dokumentum-előbeállítás mentése Preset {number} Előbeállítás {number} Save Preset… Előbeállítás mentése… Save Print Preset Nyomtatási előbeállítás mentése @@ -4164,7 +4162,6 @@ mark-style Japanese Japán Template Sablon Templates Sablonok Templates Folder Sablonok mappája - Templates… Sablonok… Tetrad Tetrád Tetrad 2 Tetrád 2 Tetrad 3 Tetrád 3 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f..9ed84d0cb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2350,15 +2350,12 @@ digits Malayalam Malayalam Selected Swatches Swatch terpilih Advanced Options Opsi lanjutan Background Contents Konten latar belakang - Background Contents: Konten latar belakang: BLANK DOCUMENT PRESETS PRESET DOKUMEN KOSONG Bleed Bleed Change to Right-to-Left Layout Ubah ke tata letak kanan ke kiri Columns: Kolom: - Create Document Buat dokumen Documents you create appear here. Dokumen yang Anda buat muncul di sini. More Settings Pengaturan lainnya - New from Template Baru dari templat Number of Artboards: Jumlah artboard: Orientation: Orientasi: PRESET DETAILS DETAIL PRESET @@ -2369,7 +2366,6 @@ digits Malayalam Malayalam RECENT TERKINI RECENT (1) TERKINI (1) SAVED TERSIMPAN - Templates… Templat… Transparent (Default) Transparan (default) Save for Office Documents Simpan untuk dokumen Office Transparent Background Latar belakang transparan @@ -3273,6 +3269,7 @@ theme Appearance Tampilan Arrange by Row Susun per baris Arrange by Column Susun per kolom Save Preset Simpan preset + Save Document Preset Simpan preset dokumen Screen (72 ppi) Layar (72 ppi) Social Square Post 1080×1080 Postingan persegi media sosial 1080×1080 Distribute Distribusikan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272..c3815cea4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -297,7 +297,6 @@ font-technology Variable Variabili Update While Dragging Aggiorna durante il trascinamento Hide Video Rulers Nascondi righelli video Background Contents Contenuto sfondo - Background Contents: Contenuto sfondo: Background: Sfondo: Backspace Backspace Bar Graph Tool Strumento Grafico a barre @@ -669,7 +668,6 @@ font-technology Variable Variabili Couldn't {} {name}: {e} Impossibile {} {name}: {e} Create Crea Create Clipping Mask Crea maschera di ritaglio - Create Document Crea documento Create from Guides Crea da guide Create from Selection Crea dalla selezione Create From: Crea da: @@ -2175,7 +2173,6 @@ file-filter Templates Modelli New Color Group… Nuovo gruppo colori… New Document Presets Preset nuovo documento New file Nuovo file - New from Template Nuovo da modello New from Template… Nuovo da modello… New Graphic Style Nuovo stile grafico New Graphic Style (Alt-click to name it) Nuovo stile grafico (Alt+clic per assegnare un nome) @@ -2967,6 +2964,7 @@ digits Malayalam Malayalam Save PDF Preset Salva preset PDF Save Perspective Grid Preset Salva preset griglia prospettica Save Preset Salva preset + Save Document Preset Salva preset documento Preset {number} Preset {number} Save Preset… Salva preset… Save Print Preset Salva preset di stampa @@ -3546,7 +3544,6 @@ digits Malayalam Malayalam Template Modello Templates Modelli Templates Folder Cartella modelli - Templates… Modelli… Text Displayed in Cell: Testo visualizzato nella cella: Text Export Options Opzioni di esportazione testo Text Fill & Stroke Color Colore riempimento e traccia testo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0..165372bcb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1076,7 +1076,6 @@ antiAlias Sharp シャープ Update While Dragging ドラッグ中に更新 Hide Video Rulers ビデオ定規を隠す Background Contents 背景のカラー - Background Contents: 背景のカラー: Background: 背景: Bar Graph Tool 棒グラフツール Baseline Shift ベースラインシフト @@ -1302,7 +1301,6 @@ antiAlias Sharp シャープ Corners… コーナー… Chamfer 面取り Create Clipping Mask クリッピングマスクを作成 - Create Document ドキュメントを作成 Create From: 作成元: Create Graph グラフを作成 Create New Layer 新規レイヤーを作成 @@ -2362,7 +2360,6 @@ antiAlias Sharp シャープ New Symbol… 新規シンボル… New Workspace 新規ワークスペース New file 新規ファイル - New from Template テンプレートから新規作成 New theme from the current color 現在のカラーから新規テーマ New {kind} Style… 新規{kind}スタイル… Next 次へ @@ -2905,6 +2902,7 @@ mark-style Japanese 日本式 Save PDF Preset PDF プリセットを保存 Save Perspective Grid Preset 遠近グリッドプリセットを保存 Save Preset プリセットを保存 + Save Document Preset ドキュメントプリセットを保存 Preset {number} プリセット {number} Save Preset… 保存… Save Print Preset プリントプリセットを保存 @@ -3253,7 +3251,6 @@ mark-style Japanese 日本式 Target Contents 内容をターゲット Target: ターゲット: Templates Folder テンプレートフォルダー - Templates… テンプレート… Text Displayed in Cell: セルに表示するテキスト: Text Export Options テキスト書き出しオプション Text Import Options テキスト読み込みオプション diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf5..4ba0bef7c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2252,15 +2252,12 @@ digits Malayalam მალაიალამური Selected Swatches მონიშნული ფერის ნიმუშები Advanced Options დამატებითი პარამეტრები Background Contents ფონის შიგთავსი - Background Contents: ფონის შიგთავსი: BLANK DOCUMENT PRESETS ცარიელი დოკუმენტის პრესეტები Bleed ჭრისპირა არე Change to Right-to-Left Layout მარჯვნიდან მარცხნივ განლაგებაზე გადასვლა Columns: სვეტები: - Create Document დოკუმენტის შექმნა Documents you create appear here. თქვენ მიერ შექმნილი დოკუმენტები აქ გამოჩნდება. More Settings მეტი პარამეტრი - New from Template შაბლონიდან შექმნა Number of Artboards: სამუშაო დაფების რაოდენობა: Orientation: ორიენტაცია: PRESET DETAILS პრესეტის დეტალები @@ -2270,7 +2267,6 @@ digits Malayalam მალაიალამური Preview Mode გადახედვის რეჟიმი RECENT ბოლოდროინდელი SAVED შენახული - Templates… შაბლონები… Transparent (Default) გამჭვირვალე (ნაგულისხმევი) Save for Office Documents შენახვა Office-ის დოკუმენტებისთვის Transparent Background გამჭვირვალე ფონი @@ -3095,6 +3091,7 @@ theme Appearance იერსახე Arrange by Row სტრიქონების მიხედვით დალაგება Arrange by Column სვეტების მიხედვით დალაგება Save Preset პრესეტის შენახვა + Save Document Preset დოკუმენტის პრესეტის შენახვა Screen (72 ppi) ეკრანი (72 ppi) Social Square Post 1080×1080 კვადრატული პოსტი 1080×1080 Distribute განაწილება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e8316..3303149a8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1066,7 +1066,6 @@ antiAlias Sharp 날카롭게 Update While Dragging 드래그하는 동안 업데이트 Hide Video Rulers 비디오 눈금자 숨기기 Background Contents 배경 내용 - Background Contents: 배경 내용: Background: 배경: Bar Graph Tool 막대 그래프 도구 Baseline Shift 기준선 이동 @@ -1292,7 +1291,6 @@ antiAlias Sharp 날카롭게 Corners… 모퉁이… Chamfer 모따기 Create Clipping Mask 클리핑 마스크 만들기 - Create Document 문서 만들기 Create From: 만들기 기준: Create Graph 그래프 만들기 Create New Layer 새 레이어 만들기 @@ -2352,7 +2350,6 @@ antiAlias Sharp 날카롭게 New Symbol… 새 심볼… New Workspace 새 작업 영역 New file 새 파일 - New from Template 템플릿에서 새로 만들기 New theme from the current color 현재 색상으로 새 테마 만들기 New {kind} Style… 새 {kind} 스타일… Next 다음 @@ -2894,6 +2891,7 @@ mark-style Japanese 일본식 Save PDF Preset PDF 사전 설정 저장 Save Perspective Grid Preset 원근 격자 사전 설정 저장 Save Preset 사전 설정 저장 + Save Document Preset 문서 사전 설정 저장 Preset {number} 사전 설정 {number} Save Preset… 사전 설정 저장… Save Print Preset 인쇄 사전 설정 저장 @@ -3242,7 +3240,6 @@ mark-style Japanese 일본식 Target Contents 대상 내용 Target: 대상: Templates Folder 템플릿 폴더 - Templates… 템플릿… Text Displayed in Cell: 셀에 표시할 텍스트: Text Export Options 텍스트 내보내기 옵션 Text Import Options 텍스트 가져오기 옵션 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3..ec86634e2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2364,15 +2364,12 @@ digits Malayalam Malayalam Selected Swatches Geselecteerde stalen Advanced Options Geavanceerde opties Background Contents Achtergrondinhoud - Background Contents: Achtergrondinhoud: BLANK DOCUMENT PRESETS LEGE DOCUMENTVOORINSTELLINGEN Bleed Afrulling Change to Right-to-Left Layout Omschakelen naar lay-out van rechts naar links Columns: Kolommen: - Create Document Document maken Documents you create appear here. Documenten die je maakt, verschijnen hier. More Settings Meer instellingen - New from Template Nieuw uit sjabloon Number of Artboards: Aantal tekenborden: Orientation: Oriëntatie: PRESET DETAILS VOORINSTELLINGSDETAILS @@ -2383,7 +2380,6 @@ digits Malayalam Malayalam RECENT RECENT RECENT (1) RECENT (1) SAVED OPGESLAGEN - Templates… Sjablonen… Transparent (Default) Transparant (standaard) Save for Office Documents Opslaan voor Office-documenten Transparent Background Transparante achtergrond @@ -3287,6 +3283,7 @@ theme Appearance Vormgeving Arrange by Row Ordenen op rij Arrange by Column Ordenen op kolom Save Preset Voorinstelling opslaan + Save Document Preset Documentvoorinstelling opslaan Screen (72 ppi) Scherm (72 ppi) Social Square Post 1080×1080 Vierkante socialmediapost 1080×1080 Distribute Verdelen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679..7b1195fe7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2476,15 +2476,12 @@ digits Malayalam Malajalam Selected Swatches Zaznaczone próbki Advanced Options Opcje zaawansowane Background Contents Zawartość tła - Background Contents: Zawartość tła: BLANK DOCUMENT PRESETS USTAWIENIA PUSTEGO DOKUMENTU Bleed Spad Change to Right-to-Left Layout Zmień na układ od prawej do lewej Columns: Kolumny: - Create Document Utwórz dokument Documents you create appear here. Tu pojawią się utworzone dokumenty. More Settings Więcej ustawień - New from Template Nowy z szablonu Number of Artboards: Liczba obszarów kompozycji: Orientation: Orientacja: PRESET DETAILS SZCZEGÓŁY USTAWIENIA @@ -2494,7 +2491,6 @@ digits Malayalam Malajalam Preview Mode Tryb podglądu RECENT OSTATNIE SAVED ZAPISANE - Templates… Szablony… Transparent (Default) Przezroczyste (domyślnie) Save for Office Documents Zapisz dla dokumentów biurowych Transparent Background Przezroczyste tło @@ -3415,6 +3411,7 @@ theme Appearance Wygląd Arrange by Row Ułóż według wierszy Arrange by Column Ułóż według kolumn Save Preset Zapisz ustawienie + Save Document Preset Zapisz ustawienie dokumentu Screen (72 ppi) Ekran (72 ppi) Social Square Post 1080×1080 Kwadratowy post społecznościowy 1080×1080 Distribute Rozmieść diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68c..b8989eee9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -283,7 +283,6 @@ antiAlias Sharp Nítido Update While Dragging Atualizar ao Arrastar Hide Video Rulers Ocultar Réguas de Vídeo Background Contents Conteúdo do Plano de Fundo - Background Contents: Conteúdo do Plano de Fundo: Background: Plano de Fundo: Bar Graph Tool Ferramenta Gráfico de Barras Baseline Shift Deslocamento da Linha de Base @@ -596,7 +595,6 @@ antiAlias Sharp Nítido Corners… Cantos… Create Criar Create Clipping Mask Criar Máscara de Recorte - Create Document Criar Documento Create From: Criar De: Create Gradient Mesh… Criar Malha de Gradiente… Create Graph Criar Gráfico @@ -1792,7 +1790,6 @@ antiAlias Sharp Nítido Duplicate Toolbar Duplicar Barra de Ferramentas Delete Toolbar Excluir Barra de Ferramentas New file Novo arquivo - New from Template Novo a partir de Modelo New from Template… Novo a partir de Modelo… New theme from the current color Novo tema a partir da cor atual New {kind} Style… Novo Estilo de {kind}… @@ -2457,6 +2454,7 @@ digits Malayalam Malaiala Save PDF Preset Salvar Predefinido de PDF Save Perspective Grid Preset Salvar Predefinido de Grade de Perspectiva Save Preset Salvar Predefinido + Save Document Preset Salvar Predefinido de Documento Save Preset… Salvar Predefinido… Save Print Preset Salvar Predefinido de Impressão Save Recovery Data Salvar Dados de Recuperação @@ -2880,7 +2878,6 @@ digits Malayalam Malaiala Target: Alvo: Temperature/Tint… Temperatura/Tom… Templates Folder Pasta de Modelos - Templates… Modelos… Text Displayed in Cell: Texto Exibido na Célula: Text Export Options Opções de Exportação de Texto Text Fill & Stroke Color Cor de Preenchimento e Traçado do Texto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e83..453594a19 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -295,7 +295,6 @@ font-technology Variable Вариативные Update While Dragging Обновлять при перетаскивании Hide Video Rulers Скрыть видео-линейки Background Contents Содержимое фона - Background Contents: Содержимое фона: Background: Фон: Backspace Backspace Bar Graph Tool Инструмент «Гистограмма» @@ -667,7 +666,6 @@ font-technology Variable Вариативные Couldn't {} {name}: {e} Не удалось {} {name}: {e} Create Создать Create Clipping Mask Создать маску обрезки - Create Document Создать документ Create from Guides Создать из направляющих Create from Selection Создать из выделения Create From: Создать из: @@ -2170,7 +2168,6 @@ file-filter Templates Шаблоны New Color Group… Новая цветовая группа… New Document Presets Наборы новых документов New file Новый файл - New from Template Создать из шаблона New from Template… Создать из шаблона… New Graphic Style Новый графический стиль New Graphic Style (Alt-click to name it) Новый графический стиль (Alt+щелчок, чтобы задать имя) @@ -2962,6 +2959,7 @@ digits Malayalam Малаялам Save PDF Preset Сохранить набор PDF Save Perspective Grid Preset Сохранить набор сетки перспективы Save Preset Сохранить набор + Save Document Preset Сохранить набор документа Preset {number} Набор {number} Save Preset… Сохранить набор… Save Print Preset Сохранить набор печати @@ -3541,7 +3539,6 @@ digits Malayalam Малаялам Template Шаблон Templates Шаблоны Templates Folder Папка шаблонов - Templates… Шаблоны… Text Displayed in Cell: Текст, отображаемый в ячейке: Text Export Options Параметры экспорта текста Text Fill & Stroke Color Цвет заливки и обводки текста diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d..b29bb9389 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -286,7 +286,6 @@ font-technology Variable Variabla Update While Dragging Uppdatera vid dragning Hide Video Rulers Dölj videolinjaler Background Contents Bakgrundsinnehåll - Background Contents: Bakgrundsinnehåll: Background: Bakgrund: Backspace Backspace Bar Graph Tool Stapeldiagramverktyg @@ -656,7 +655,6 @@ font-technology Variable Variabla Couldn't {} {name}: {e} Det gick inte att {} {name}: {e} Create Skapa Create Clipping Mask Skapa klippmask - Create Document Skapa dokument Create from Guides Skapa från stödlinjer Create from Selection Skapa från markering Create From: Skapa från: @@ -2085,7 +2083,6 @@ font-technology Variable Variabla New Color Group… Ny färggrupp… New Document Presets Förinställningar för nytt dokument New file Ny fil - New from Template Ny från mall New from Template… Ny från mall… New Graphic Style Ny grafikstil New Graphic Style (Alt-click to name it) Ny grafikstil (Alt-klicka för att namnge den) @@ -2869,6 +2866,7 @@ digits Malayalam Malayalam Save PDF Preset Spara PDF-förinställning Save Perspective Grid Preset Spara förinställning för perspektivrutnät Save Preset Spara förinställning + Save Document Preset Spara dokumentförinställning Save Preset… Spara förinställning… Save Print Preset Spara utskriftsförinställning Save Recovery Data Spara återställningsdata @@ -3432,7 +3430,6 @@ digits Malayalam Malayalam Template Mall Templates Mallar Templates Folder Mallmapp - Templates… Mallar… Text Displayed in Cell: Text som visas i cellen: Text Export Options Alternativ för textexport Text Fill & Stroke Color Textens fyllnads- och konturfärg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006c..4e7619b7e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -282,7 +282,6 @@ font-technology Variable Değişken Update While Dragging Sürüklerken Güncelle Hide Video Rulers Video Cetvellerini Gizle Background Contents Arka Plan İçeriği - Background Contents: Arkaplan İçeriği: Background: Arka plan: Backspace Geri tuşu Bar Graph Tool Çubuk Grafik Aracı @@ -648,7 +647,6 @@ font-technology Variable Değişken Couldn't {} {name}: {e} {} yapılamadı {name}: {e} Create Oluştur Create Clipping Mask Kırpma Maskesi Oluştur - Create Document Belge Oluştur Create from Guides Rehberlerden Oluştur Create from Selection Seçimden Oluştur Create From: Şuradan Oluştur: @@ -1958,7 +1956,6 @@ font-technology Variable Değişken New Color Group… Yeni Renk Grubu… New Document Presets Yeni Belge Ön Ayarları New file Yeni dosya - New from Template Şablondan yeni New from Template… Şablondan yeni… New Graphic Style Yeni Grafik Stili New Graphic Style (Alt-click to name it) Yeni Grafik Stili (Adını vermek için Alt tuşuna basarak tıklayın) @@ -2734,6 +2731,7 @@ digits Malayalam Malayalam Save PDF Preset PDF Hazır Ayarını Kaydet Save Perspective Grid Preset Perspektif Izgarası Ön Ayarını Kaydet Save Preset Ön Ayarı Kaydet + Save Document Preset Belge Ön Ayarını Kaydet Save Preset… Ön Ayarı Kaydet… Save Print Preset Yazdırma Ön Ayarını Kaydet Save Recovery Data Kurtarma Verilerini Kaydet @@ -3323,7 +3321,6 @@ digits Malayalam Malayalam Template Şablon Templates Şablonlar Templates Folder Şablonlar Klasörü - Templates… Şablonlar… Text Displayed in Cell: Hücrede Görüntülenen Metin: Text Export Options Metin Dışa Aktarma Seçenekleri Text Fill & Stroke Color Metin Dolgu ve Kontur Rengi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a5..5729680e6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -291,7 +291,6 @@ font-technology Variable Варіативні Update While Dragging Оновлювати під час перетягування Hide Video Rulers Приховати відео-лінійки Background Contents Вміст фону - Background Contents: Вміст фону: Background: Фон: Backspace Backspace Bar Graph Tool Інструмент «Гістограма» @@ -663,7 +662,6 @@ font-technology Variable Варіативні Couldn't {} {name}: {e} Не вдалося {} {name}: {e} Create Створити Create Clipping Mask Створити відсічну маску - Create Document Створити документ Create from Guides Створити з напрямних Create from Selection Створити із виділення Create From: Створити з: @@ -2167,7 +2165,6 @@ file-filter Templates Шаблони New Color Group… Нова колірна група… New Document Presets Набір нових документів New file Новий файл - New from Template Створити із шаблону New from Template… Створити із шаблону… New Graphic Style Новий графічний стиль New Graphic Style (Alt-click to name it) Новий графічний стиль (Alt+клацання, щоб задати ім’я) @@ -2959,6 +2956,7 @@ digits Malayalam Малаялам Save PDF Preset Зберегти набір PDF Save Perspective Grid Preset Зберегти набір сітки перспективи Save Preset Зберегти набір + Save Document Preset Зберегти набір документа Preset {number} Набір {number} Save Preset… Зберегти набір… Save Print Preset Зберегти набір друку @@ -3539,7 +3537,6 @@ effect Points Точки Template Шаблон Templates Шаблони Templates Folder Папка шаблонів - Templates… Шаблони… Text Displayed in Cell: Текст, що відображається в комірці: Text Export Options Параметри експорту тексту Text Fill & Stroke Color Колір заливки та обведення тексту diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003..ea1d2ed9e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -320,7 +320,6 @@ font-technology Variable Biến thiên Update While Dragging Cập nhật khi kéo Hide Video Rulers Ẩn thước video Background Contents Nội dung nền - Background Contents: Nội dung nền: Background: Nền: Backspace Backspace Balance Cân bằng @@ -765,7 +764,6 @@ font-technology Variable Biến thiên Create Tạo Create Clipping Mask Tạo mặt nạ cắt Create Compound Path From Ellipses Tạo đường dẫn phức từ hình elip - Create Document Tạo tài liệu Create from Guides Tạo từ đường căn Create from Selection Tạo từ vùng chọn Create From: Tạo từ: @@ -2408,7 +2406,6 @@ digits Hindi Hindi New Design Thiết kế mới New Document Presets Thiết lập sẵn tài liệu mới New file Tệp mới - New from Template Mới từ mẫu New from Template… Mới từ mẫu… New Graphic Style Kiểu đồ họa mới New Graphic Style (Alt-click to name it) Kiểu đồ họa mới (Alt-bấm để đặt tên) @@ -3272,6 +3269,7 @@ mark-style Japanese Kiểu Nhật Save PDF Preset Lưu thiết lập sẵn PDF Save Perspective Grid Preset Lưu thiết lập sẵn lưới phối cảnh Save Preset Lưu thiết lập sẵn + Save Document Preset Lưu thiết lập sẵn tài liệu Preset {number} Thiết lập sẵn {number} Save Preset… Lưu thiết lập sẵn… Save Print Preset Lưu thiết lập sẵn in @@ -3873,7 +3871,6 @@ mark-style Japanese Kiểu Nhật Template Mẫu Templates Mẫu Templates Folder Thư mục mẫu - Templates… Mẫu… Text Văn bản Text Displayed in Cell: Văn bản hiển thị trong ô: Text Export Options Tùy chọn xuất văn bản diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d..7ce4da2fd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -243,7 +243,6 @@ antiAlias Sharp 锐利 Update While Dragging 拖动时更新 Hide Video Rulers 隐藏视频标尺 Background Contents 背景内容 - Background Contents: 背景内容: Background: 背景: Backspace Backspace Bar Graph Tool 条形图工具 @@ -614,7 +613,6 @@ antiAlias Sharp 锐利 Couldn't {} {name}: {e} 无法{} {name}:{e} Create 创建 Create Clipping Mask 创建剪切蒙版 - Create Document 创建文档 Create from Guides 从参考线创建 Create from Selection 从选区创建 Create From: 创建自: @@ -2044,7 +2042,6 @@ file-filter Templates 模板 New Color Group… 新建颜色组… New Document Presets 新建文档预设 New file 新建文件 - New from Template 从模板新建 New from Template… 从模板新建… New Graphic Style 新建图形样式 New Graphic Style (Alt-click to name it) 新建图形样式 (Alt+点击可命名) @@ -2828,6 +2825,7 @@ digits Malayalam 马拉雅拉姆数字 Save PDF Preset 保存 PDF 预设 Save Perspective Grid Preset 存储透视网格预设 Save Preset 保存预设 + Save Document Preset 保存文档预设 Preset {number} 预设 {number} Save Preset… 存储预设… Save Print Preset 保存打印预设 @@ -3374,7 +3372,6 @@ digits Malayalam 马拉雅拉姆数字 Template 模板 Templates 模板 Templates Folder 模板文件夹 - Templates… 模板… Text Displayed in Cell: 单元格中显示的文字: Text Export Options 文本导出选项 Text Fill & Stroke Color 文字填色和描边颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b..961118d55 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -256,7 +256,6 @@ font-technology Variable 可變 Update While Dragging 拖曳時更新 Hide Video Rulers 隱藏視訊尺標 Background Contents 背景內容 - Background Contents: 背景內容: Background: 背景: Backspace Backspace Bar Graph Tool 橫條圖工具 @@ -631,7 +630,6 @@ font-technology Variable 可變 Couldn't {} {name}: {e} 無法{} {name}:{e} Create 建立 Create Clipping Mask 建立剪裁遮色片 - Create Document 建立文件 Create from Guides 從參考線建立 Create from Selection 從選取範圍建立 Create From: 建立來源: @@ -3064,7 +3062,6 @@ file-filter Templates 範本 New Color Group… 新增顏色群組… New Document Presets 新增文件預設集 New file 新增檔案 - New from Template 從範本新增 New from Template… 從範本新增… New Graphic Style 新增繪圖樣式 New Graphic Style (Alt-click to name it) 新增繪圖樣式 (Alt+點擊可命名) @@ -3861,6 +3858,7 @@ digits Malayalam 馬來亞拉姆數字 Save PDF Preset 儲存 PDF 預設集 Save Perspective Grid Preset 儲存透視格點預設集 Save Preset 儲存預設集 + Save Document Preset 儲存文件預設集 Preset {number} 預設集 {number} Save Preset… 儲存預設集… Save Print Preset 儲存列印預設集 @@ -4438,7 +4436,6 @@ digits Malayalam 馬來亞拉姆數字 Template 範本 Templates 範本 Templates Folder 範本資料夾 - Templates… 範本… Text Displayed in Cell: 儲存格中顯示的文字: Text Export Options 文字匯出選項 Text Fill & Stroke Color 文字填色與筆畫顏色 diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 6d7ac9474..63e311aea 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -226,7 +226,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } // Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and // leaves the dialog open. - let popup = app.ui.dialog.as_ref().is_some_and(|d| egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d)); + let popup = app.ui.dialog.as_ref().is_some_and(|d| { + egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d) || crate::dialogs::new_document_popup_open(d) + }); // Escape in Preferences' search field with text clears the field first. if !popup && pressed_anew(ctx, Key::Escape) && !crate::prefs_dialog::clear_search_on_escape(app, ctx) { crate::dialogs::cancel(app); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2cb542c6b..7a4aa6c6b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -572,9 +572,15 @@ New Document: File → New… (`file.newDialog`, Cmd+N) opens the `newDocument` `colorMode`, `rasterEffectsPpi`, `previewMode`) plus `category` (the tab: Recent, Saved, Mobile, Web, Print, Film & Video, Art & Illustration, Branding, Social), `advanced` (Advanced Options open), `bleedLinked` and `presetName` (Save Preset's name). Setting `preset` to a name from `file.newPresets` fills the other fields from that preset; -changing a field afterwards makes the settings custom (`preset` becomes ""). More Settings is the `newDocumentMore` -dialog over the same fields (its Profile is `category`). `ui.dialog.confirm` runs `file.new` with the fields (with -`previewMode: "pixel"` the app turns Pixel Preview on); on an error the dialog stays open. +changing a field afterwards makes the settings custom (`preset` becomes ""). The `newDocumentMore` dialog is New +Document with the More Settings pop-up open over it, which edits `artboards` and `artboardLayout` (layout, right-to-left, +spacing and columns). While `__saving` is true, Preset Details shows Save Document Preset with the `presetName` field, +Cancel and Save in place of the document's name and hides Close and Create. `ui.dialog.cancel` closes the More Settings +pop-up first, then leaves saving, and keeps the fields each time. `ui.dialog.confirm` runs `file.new` with the fields +(with `previewMode: "pixel"` the app turns Pixel Preview on); on an error the dialog stays open. While `__saving` is +true, `ui.dialog.confirm` saves the preset and does not create a document; after a save `presetName` is empty, and the +next Save Preset fills it with the first `Preset N` that no saved preset has. `ui.click` on the More Settings button +opens the `newDocumentMore` pop-up. Swatch Conflict: through the app, when the pasted objects' global or spot swatches conflict with the document's (see `clipboard.conflicts`) and the params have no `swatchConflict`, every Paste command opens the `swatchConflict` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a93770b88..b4bcc9f8e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -649,6 +649,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. +- **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). From d8d73495bb135a0c4bcddf26cccf505eff32ec54 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:05:17 -0800 Subject: [PATCH 11/48] Revolve: a group member's blending mode applies to its surface A member of a group with Revolve drew its surface in Normal mode whatever its own blending mode or that of a group it is in. Each mode now applies once, to the member's surface or to the surfaces of the group that has it, nested as the groups are. Expand Appearance keeps every face of a blended member. --- crates/effects/src/revolve.rs | 74 ++++++++++++++++++++++--- crates/effects/src/solids.rs | 39 +++++++++---- crates/effects/src/solids/tests_mesh.rs | 2 +- 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 58125c661..0a8479d61 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, Rect}; use vectorcraft_three_d::{Edge, Revolve}; @@ -431,6 +431,36 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } +/// The members' `surfaces` in order, from the blending mode at `level` of each one's +/// `Profile::blends` inward: the surfaces of consecutive members that share a group with a mode +/// other than Normal go in one group with that mode, so each mode applies once, at its own level. +fn blended(id: NodeId, surfaces: Vec<(&[(usize, BlendMode)], Node)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[(usize, BlendMode)], Node)>, key: Option<(usize, BlendMode)>, level: usize, out: &mut Vec>) { + if let Some((_, blend)) = key { + let mut group = Node::group(id, blended(id, std::mem::take(run), level + 1)); + group.blend = blend; + out.push(Arc::new(group)); + } + } + let mut out = vec![]; + let mut run = vec![]; + let mut key = None; + for (blends, s) in surfaces { + let at = blends.get(level).copied(); + if at != key { + close(id, &mut run, key, level, &mut out); + key = at; + } + if key.is_some() { + run.push((blends, s)); + } else { + out.push(Arc::new(s)); + } + } + close(id, &mut run, key, level, &mut out); + out +} + /// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, /// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. @@ -449,16 +479,19 @@ fn group_art(n: &Node, expand: bool) -> Option { let Some(item) = surface_item(&s.items) else { continue }; let (paint, opacity, blend) = paint_of(item); let opacity = opacity * s.opacity; - opaque &= opacity >= 1.0 && blend == Default::default(); - surfaces.push(surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?); + opaque &= opacity >= 1.0 && blend == Default::default() && s.blends.is_empty(); + // A member with non-normal blending keeps every face, as a blended object does. + let expand = expand && s.blends.is_empty(); + let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; + surfaces.push((depths, art, s.blends.as_slice())); } if surfaces.is_empty() { return None; } - let plain = surfaces.iter().all(|(depths, s)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); let children = if opaque && plain { let mut faces = vec![]; - for (depths, s) in surfaces { + for (depths, s, _) in surfaces { if let NodeKind::Group { children, .. } = s.kind { faces.extend(depths.into_iter().zip(children)); } @@ -467,7 +500,7 @@ fn group_art(n: &Node, expand: bool) -> Option { faces.sort_by(|a, b| a.0.total_cmp(&b.0)); vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] } else { - surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect() + blended(n.id, surfaces.into_iter().map(|(_, s, blends)| (blends, s)).collect(), 0) }; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -717,6 +750,33 @@ mod tests { assert_eq!(revolve_art(&both), None, "past the limits the source draws"); } + #[test] + fn blending_modes_apply_once_to_the_member_or_group_that_has_them() { + let blend = |n: Arc, mode: BlendMode| { + let mut n = (*n).clone(); + n.blend = mode; + Arc::new(n) + }; + let inner = blend(member(4, 60.0), BlendMode::Multiply); + let screened = blend(Arc::new(Node::group(NodeId(5), vec![member(3, 40.0), inner])), BlendMode::Screen); + let group = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply), member(2, 20.0), screened]); + let art = revolve_art(&group).expect("revolved"); + let modes = |n: &Node| match &n.kind { + NodeKind::Group { children, .. } => children.iter().map(|c| c.blend).collect::>(), + _ => panic!("a group"), + }; + assert_eq!(modes(&art), [BlendMode::Multiply, BlendMode::Normal, BlendMode::Screen], "a surface or group for each member"); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let screened = &children[2]; + assert_eq!(modes(screened), [BlendMode::Normal, BlendMode::Multiply], "the screened group's members, together, in their own modes"); + let NodeKind::Group { children: inner, .. } = &screened.kind else { panic!("a group") }; + assert_eq!(modes(&inner[1]).len(), 1, "the multiplied member's surface"); + let plain = group_with(json!({}), vec![member(1, 0.0)]); + assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed"); + let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]); + assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face"); + } + #[test] fn a_3d_effect_on_a_groups_fill_is_refused() { let mut group = Node::group(NodeId(9), vec![member(1, 0.0)]); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 568bf2be9..c4b55fd5e 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -225,12 +225,16 @@ pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRul } /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint -/// it, and the opacity of the objects it is in (1 for the object with the effect itself). +/// it, the opacity of the objects it is in (1 for the object with the effect itself) and their +/// blending modes other than Normal (none for the object itself). `blends` runs from the outermost +/// group in the effect's group down to the member itself, each with a key that the members of +/// one group share. Group Revolve draws the surfaces in those modes. pub(crate) struct Profile { pub(crate) path: PathData, pub(crate) rule: FillRule, pub(crate) items: Vec, pub(crate) opacity: f32, + pub(crate) blends: Vec<(usize, BlendMode)>, } /// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? @@ -256,14 +260,25 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000; /// reference app, they turn about one shared axis (the centre of their bounds together) rather /// than each about its own. An error names what the group holds that can't become a solid. pub(crate) fn members(n: &Node, label: &str) -> Result, String> { - fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec) -> Result<(), String> { + struct Walk<'a> { + label: &'a str, + out: Vec, + keys: usize, + } + fn walk(n: &Node, opacity: f32, blends: &[(usize, BlendMode)], w: &mut Walk) -> Result<(), String> { + let label = w.label; for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { continue; } let opacity = opacity * c.opacity; + let mut blends = blends.to_vec(); + if c.blend != BlendMode::Normal { + w.keys += 1; + blends.push((w.keys, c.blend)); + } if is_container(c) { - walk(c, opacity, label, out)?; + walk(c, opacity, &blends, w)?; continue; } let Some((path, rule)) = profile(c) else { @@ -276,19 +291,19 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { }; return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first.")); }; - if out.len() >= MAX_MEMBERS { + if w.out.len() >= MAX_MEMBERS { return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects")); } let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c)); if path.bounds().is_some() { - out.push(Profile { path, rule, items: solid_paints(c), opacity }); + w.out.push(Profile { path, rule, items: solid_paints(c), opacity, blends }); } } Ok(()) } - let mut out = vec![]; - walk(n, 1.0, label, &mut out)?; - Ok(out) + let mut w = Walk { label, out: vec![], keys: 0 }; + walk(n, 1.0, &[], &mut w)?; + Ok(w.out) } /// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked @@ -524,7 +539,7 @@ pub fn solid_art(n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?); + children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, blends: vec![] }])?); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -540,7 +555,7 @@ pub fn solid_art(n: &Node) -> Option { for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? { + for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, blends: vec![] }])? { Arc::make_mut(&mut art).appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); children.push(art); @@ -703,14 +718,14 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result Date: Mon, 9 Nov 2026 18:12:27 -0800 Subject: [PATCH 12/48] Revolve: the search for holes looks only at contours near each point Mesh export tested the middle of every segment against every closed contour's bounds, so a profile of thousands of separate contours took hundreds of millions of comparisons before the work limit counted any. The closed contours now go in a grid over their bounds, a point is tested only against the contours of its cell, and placing a contour in a cell and each candidate looked at count toward the limit. --- crates/three-d/src/lib.rs | 52 ++++++++++++++++++---- crates/three-d/src/tests/profile_limits.rs | 15 +++++++ docs/mcp.md | 2 +- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 54bc0c5a6..88843cb4b 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -508,14 +508,23 @@ pub fn revolve_curve_samples<'a>(paths: impl IntoIterator, if samples < MIN_CURVE_SAMPLES { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(samples) } } -/// Most path elements the search for holes tests a point against, each winding test counting its -/// contour's elements. +/// Most work the search for holes does: each winding test counts its contour's elements, and each +/// contour placed in a cell of the grid or looked at as a candidate for a point counts one. const MAX_HOLE_WORK: usize = 16_000_000; +/// Most cells along each side of the grid the search for holes sorts contours into. +const MAX_HOLE_GRID: usize = 256; + /// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is /// outside its closed contour at the segment's middle and not inside. An error past /// [`MAX_HOLE_WORK`]. fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { + hole_segments_counted(path, rule).map(|(holes, _)| holes) +} + +/// [`hole_segments`] and the work it did. A point is tested only against the closed contours whose +/// bounds reach into its cell of a grid over the bounds of them all. +fn hole_segments_counted(path: &PathData, rule: FillRule) -> Result<(Vec>, usize), String> { let contours: Vec<(BezPath, Rect)> = path .subpaths .iter() @@ -527,6 +536,33 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri .collect(); let closed = |j: usize| path.subpaths.get(j).is_some_and(|sp| sp.closed); let mut work = 0usize; + let spend = |n: usize, work: &mut usize| { + *work = work.saturating_add(n); + if *work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } + }; + let area = contours.iter().enumerate().filter(|(j, _)| closed(*j)).map(|(_, (_, b))| *b).reduce(|a, b| a.union(b)); + let count = (0..contours.len()).filter(|j| closed(*j)).count(); + let side = ((count as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); + let at = |v: f64, lo: f64, hi: f64| { + let span = hi - lo; + if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } + }; + let cell = |p: Point| area.map(|a| at(p.y, a.y0, a.y1) * side + at(p.x, a.x0, a.x1)); + let mut grid: Vec> = vec![vec![]; side * side]; + if let Some(a) = area { + for (j, (_, b)) in contours.iter().enumerate().filter(|(j, _)| closed(*j)) { + let (x0, x1) = (at(b.x0, a.x0, a.x1), at(b.x1, a.x0, a.x1)); + let (y0, y1) = (at(b.y0, a.y0, a.y1), at(b.y1, a.y0, a.y1)); + spend((x1 - x0 + 1).saturating_mul(y1 - y0 + 1), &mut work)?; + for y in y0..=y1 { + for x in x0..=x1 { + if let Some(c) = grid.get_mut(y * side + x) { + c.push(j); + } + } + } + } + } let mut holes = Vec::with_capacity(contours.len()); for (i, (sp, (own, _))) in path.subpaths.iter().zip(&contours).enumerate() { if !sp.closed { @@ -538,11 +574,11 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri for segment in 0..sp.anchors.len() { let p = sp.segment(segment).eval(0.5); let mut others = 0i32; - for (_, (contour, _)) in contours.iter().enumerate().filter(|(j, (_, bounds))| *j != i && closed(*j) && bounds.contains(p)) { - work = work.saturating_add(contour.elements().len()); - if work > MAX_HOLE_WORK { - return Err(TOO_COMPLEX_PROFILE.into()); - } + let candidates = cell(p).and_then(|c| grid.get(c)).map_or(&[][..], Vec::as_slice); + spend(candidates.len(), &mut work)?; + for &j in candidates { + let Some((contour, _)) = contours.get(j).filter(|(_, bounds)| j != i && bounds.contains(p)) else { continue }; + spend(contour.elements().len(), &mut work)?; others += contour.winding(p); } segments.push(match rule { @@ -552,7 +588,7 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri } holes.push(segments); } - Ok(holes) + Ok((holes, work)) } /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs index 025f2d9df..5eeb10f2a 100644 --- a/crates/three-d/src/tests/profile_limits.rs +++ b/crates/three-d/src/tests/profile_limits.rs @@ -105,6 +105,21 @@ fn deeply_nested_contours_stop_the_search_for_holes() { assert_eq!(hole_segments(&few, FillRule::EvenOdd).map(|h| h.len()), Ok(4)); } +#[test] +fn a_point_is_tested_only_against_the_contours_near_it() { + // 3,000 separate squares: each segment's middle lies in the bounds of its own square only. + let squares = (0..3000) + .map(|i| { + let (x, y) = ((i % 60) as f64 * 20.0, (i / 60) as f64 * 20.0); + let corners = [(x, y), (x + 10.0, y), (x + 10.0, y + 10.0), (x, y + 10.0)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }) + .collect(); + let (holes, work) = hole_segments_counted(&PathData::new(squares), FillRule::NonZero).unwrap(); + assert!(holes.iter().flatten().all(|hole| !hole)); + assert!(work < 200_000, "{work}"); +} + #[test] fn overlapping_contours_are_holes_only_where_they_overlap() { let square = |x: f64, y: f64, side: f64| { diff --git a/docs/mcp.md b/docs/mcp.md index 499d02bd4..0e73231dc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2388,7 +2388,7 @@ compound path, type, or a group or layer of them. `effect.dialog` opens compact `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. -A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export tests each segment's middle against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` From c770e3bfeac4003922a5a950fc72075b164271dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:19:22 -0800 Subject: [PATCH 13/48] Revolve: mesh export cuts an edge where it crosses another contour Mesh export decided whether an edge walls a hole at the edge's middle alone, so an edge that crosses another closed contour turned its whole length toward the hole, or none of it. Each sampled piece of a closed contour is now cut where it crosses another closed contour, and each part faces a hole only where it walls one. --- crates/three-d/src/lib.rs | 214 +++++++++++++++++++++---------- crates/three-d/src/mesh/tests.rs | 17 +++ docs/mcp.md | 2 +- 3 files changed, 167 insertions(+), 66 deletions(-) diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 88843cb4b..479481c2a 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -518,77 +518,152 @@ const MAX_HOLE_GRID: usize = 256; /// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is /// outside its closed contour at the segment's middle and not inside. An error past /// [`MAX_HOLE_WORK`]. +#[cfg(test)] fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { hole_segments_counted(path, rule).map(|(holes, _)| holes) } -/// [`hole_segments`] and the work it did. A point is tested only against the closed contours whose -/// bounds reach into its cell of a grid over the bounds of them all. +/// [`hole_segments`] and the work it did. +#[cfg(test)] fn hole_segments_counted(path: &PathData, rule: FillRule) -> Result<(Vec>, usize), String> { - let contours: Vec<(BezPath, Rect)> = path - .subpaths - .iter() - .map(|sp| { - let contour = PathData::new(vec![sp.clone()]).to_bezpath(); - let bounds = contour.bounding_box(); - (contour, bounds) - }) - .collect(); - let closed = |j: usize| path.subpaths.get(j).is_some_and(|sp| sp.closed); - let mut work = 0usize; - let spend = |n: usize, work: &mut usize| { - *work = work.saturating_add(n); - if *work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } - }; - let area = contours.iter().enumerate().filter(|(j, _)| closed(*j)).map(|(_, (_, b))| *b).reduce(|a, b| a.union(b)); - let count = (0..contours.len()).filter(|j| closed(*j)).count(); - let side = ((count as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); - let at = |v: f64, lo: f64, hi: f64| { - let span = hi - lo; - if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } - }; - let cell = |p: Point| area.map(|a| at(p.y, a.y0, a.y1) * side + at(p.x, a.x0, a.x1)); - let mut grid: Vec> = vec![vec![]; side * side]; - if let Some(a) = area { - for (j, (_, b)) in contours.iter().enumerate().filter(|(j, _)| closed(*j)) { - let (x0, x1) = (at(b.x0, a.x0, a.x1), at(b.x1, a.x0, a.x1)); - let (y0, y1) = (at(b.y0, a.y0, a.y1), at(b.y1, a.y0, a.y1)); - spend((x1 - x0 + 1).saturating_mul(y1 - y0 + 1), &mut work)?; - for y in y0..=y1 { - for x in x0..=x1 { - if let Some(c) = grid.get_mut(y * side + x) { - c.push(j); - } + let mut search = HoleSearch::new(path, rule)?; + let mut holes = Vec::with_capacity(path.subpaths.len()); + for (i, sp) in path.subpaths.iter().enumerate() { + let segments = if sp.closed { sp.anchors.len() } else { 0 }; + holes.push((0..segments).map(|segment| search.is_hole(i, sp.segment(segment).eval(0.5))).collect::, _>>()?); + } + Ok((holes, search.work)) +} + +/// The closed contours of a profile sorted into a grid over the bounds of them all, each listed in +/// the cells its bounds reach into, to find which parts of the profile's edges wall a hole under +/// its fill rule. A point is tested only against the contours of its cell. Every test counts +/// toward [`MAX_HOLE_WORK`]. +struct HoleSearch { + /// Each subpath's outline, its bounds, and for a closed one which way it winds. + contours: Vec<(BezPath, Rect, Option)>, + rule: FillRule, + area: Option, + side: usize, + grid: Vec>, + work: usize, +} + +impl HoleSearch { + fn new(path: &PathData, rule: FillRule) -> Result { + let contours: Vec<(BezPath, Rect, Option)> = path + .subpaths + .iter() + .map(|sp| { + let contour = PathData::new(vec![sp.clone()]).to_bezpath(); + let bounds = contour.bounding_box(); + let direction = sp.closed.then(|| if contour.area() < 0.0 { -1 } else { 1 }); + (contour, bounds, direction) + }) + .collect(); + let closed = || contours.iter().enumerate().filter(|(_, (_, _, d))| d.is_some()); + let area = closed().map(|(_, (_, b, _))| *b).reduce(|a, b| a.union(b)); + let side = ((closed().count() as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); + let mut search = HoleSearch { contours: vec![], rule, area, side, grid: vec![vec![]; side * side], work: 0 }; + for (j, (_, b, _)) in closed() { + let cells = search.cells(*b); + search.spend(cells.len())?; + for c in cells { + if let Some(c) = search.grid.get_mut(c) { + c.push(j); } } } + search.contours = contours; + Ok(search) } - let mut holes = Vec::with_capacity(contours.len()); - for (i, (sp, (own, _))) in path.subpaths.iter().zip(&contours).enumerate() { - if !sp.closed { - holes.push(vec![]); - continue; + + fn spend(&mut self, n: usize) -> Result<(), String> { + self.work = self.work.saturating_add(n); + if self.work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } + } + + /// The grid cells `r` reaches into. + fn cells(&self, r: Rect) -> Vec { + let Some(a) = self.area else { return vec![] }; + let side = self.side; + let at = |v: f64, lo: f64, hi: f64| { + let span = hi - lo; + if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } + }; + let (x0, x1) = (at(r.x0, a.x0, a.x1), at(r.x1, a.x0, a.x1)); + let (y0, y1) = (at(r.y0, a.y0, a.y1), at(r.y1, a.y0, a.y1)); + (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| y * side + x)).collect() + } + + /// The closed contours other than `own` whose bounds may meet `r`, each once. + fn candidates(&mut self, own: usize, r: Rect) -> Result, String> { + let mut out = vec![]; + for c in self.cells(r) { + let listed = self.grid.get(c).map_or(&[][..], Vec::as_slice); + out.extend(listed.iter().copied().filter(|j| *j != own)); } - let direction = if own.area() < 0.0 { -1 } else { 1 }; - let mut segments = Vec::with_capacity(sp.anchors.len()); - for segment in 0..sp.anchors.len() { - let p = sp.segment(segment).eval(0.5); - let mut others = 0i32; - let candidates = cell(p).and_then(|c| grid.get(c)).map_or(&[][..], Vec::as_slice); - spend(candidates.len(), &mut work)?; - for &j in candidates { - let Some((contour, _)) = contours.get(j).filter(|(_, bounds)| j != i && bounds.contains(p)) else { continue }; - spend(contour.elements().len(), &mut work)?; - others += contour.winding(p); + self.spend(out.len())?; + out.sort_unstable(); + out.dedup(); + Ok(out) + } + + /// Whether the edge of closed subpath `own` at `p` walls a hole: the fill is outside its + /// contour there and not inside. + fn is_hole(&mut self, own: usize, p: Point) -> Result { + let Some(direction) = self.contours.get(own).and_then(|c| c.2) else { return Ok(false) }; + let mut others = 0i32; + for j in self.candidates(own, Rect::from_points(p, p))? { + let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue }; + if !bounds.contains(p) { + continue; } - segments.push(match rule { - FillRule::EvenOdd => others % 2 != 0, - FillRule::NonZero => others != 0 && others + direction == 0, - }); + let (elements, winding) = (contour.elements().len(), contour.winding(p)); + self.spend(elements)?; + others += winding; } - holes.push(segments); + Ok(match self.rule { + FillRule::EvenOdd => others % 2 != 0, + FillRule::NonZero => others != 0 && others + direction == 0, + }) + } + + /// The line from `a` to `b` on subpath `own`, cut where it crosses another closed contour: each + /// part's end, the last one `b`, and whether that part walls a hole at its middle. + fn parts(&mut self, own: usize, a: Point, b: Point) -> Result, String> { + let mut out = vec![]; + let mut from = 0.0; + for t in self.crossings(own, a, b)?.into_iter().chain([1.0]) { + let hole = self.is_hole(own, a.lerp(b, (from + t) * 0.5))?; + out.push((if t < 1.0 { a.lerp(b, t) } else { b }, hole)); + from = t; + } + Ok(out) + } + + /// Where the line from `a` to `b` on closed subpath `own` crosses another closed contour, as + /// sorted fractions of the way from `a` to `b`, strictly between them. + fn crossings(&mut self, own: usize, a: Point, b: Point) -> Result, String> { + if self.contours.get(own).and_then(|c| c.2).is_none() { + return Ok(vec![]); + } + let line = vectorcraft_geom::kurbo::Line::new(a, b); + let reach = Rect::from_points(a, b); + let mut out = vec![]; + for j in self.candidates(own, reach)? { + let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue }; + if bounds.x0 > reach.x1 || bounds.x1 < reach.x0 || bounds.y0 > reach.y1 || bounds.y1 < reach.y0 { + continue; + } + let found: Vec = contour.segments().flat_map(|s| s.intersect_line(line)).map(|i| i.line_t).collect(); + self.spend(contour.elements().len())?; + out.extend(found.into_iter().filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9)); + } + out.sort_by(f64::total_cmp); + out.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + Ok(out) } - Ok((holes, work)) } /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, @@ -605,7 +680,9 @@ fn revolve_faces( Some(samples) => samples.clamp(1, CURVE_SAMPLES), None => revolve_curve_samples([path], o)?, }; - let holes = if mesh.is_some() { hole_segments(path, rule)? } else { vec![] }; + // For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so + // each part's normal turns toward a hole only where it walls one. + let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None }; // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { mesh.push((vec![face], normal)); @@ -688,12 +765,19 @@ fn revolve_faces( let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2(); if v.hypot2() > 1e-20 { v } else { b - a } }; - tangents.push([tangent(t0), tangent(t1)]); - hole_edges.push(holes.get(contour).and_then(|h| h.get(i)).copied().unwrap_or(false)); - profile.push(b); - count += 1; - if count > MAX_PROFILE_POINTS { - return Err(TOO_COMPLEX_PROFILE.into()); + let a = profile.last().copied().unwrap_or(b); + let parts = match search.as_mut() { + Some(search) => search.parts(contour, a, b)?, + None => vec![(b, false)], + }; + for (end, hole) in parts { + tangents.push([tangent(t0), tangent(t1)]); + hole_edges.push(hole); + profile.push(end); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } } } } diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 51f7c59e9..2011d6689 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -308,6 +308,23 @@ fn the_wall_of_a_counter_faces_into_the_hole() { assert!(!inner.is_empty() && inner.iter().all(|s| *s > 0.0)); } +#[test] +fn an_edge_that_crosses_another_contour_faces_the_hole_only_where_it_walls_it() { + // Two squares overlapping under the even-odd rule leave their overlap empty. The first square's + // right edge (110 pt from the axis) and the second's left edge (50 pt from it) each wall the + // overlap along part of their length and the outside along the rest. + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 40.0, 40.0, 100.0, false); + let (p, r) = shape(&b); + let m = revolve_mesh(&p, FillRule::EvenOdd, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap(); + for radius in [110.0, 50.0] { + let walls = wall_directions(&m, radius); + assert!(walls.iter().any(|s| *s > 0.0) && walls.iter().any(|s| *s < 0.0), "both ways at {radius}"); + } + assert!(wall_directions(&m, 150.0).iter().all(|s| *s > 0.0), "the second square's far edge faces out"); +} + #[test] fn inflate_puffs_the_front_toward_the_viewer() { let mut b = BezPath::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 0e73231dc..056b82324 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2388,7 +2388,7 @@ compound path, type, or a group or layer of them. `effect.dialog` opens compact `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. -A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export tests each segment's middle against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export cuts each sampled piece of a closed contour where it crosses another closed contour, adding a profile point at each cut, and tests the middle of each part against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` From 2e81086c384ecfc5ed53244ad76e092576f8d5d3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:30:41 -0800 Subject: [PATCH 14/48] Revolve: a nested group's opacity applies once to its members together The opacity of a group inside a revolved group was multiplied into each member's surface, so where members overlap they drew darker than the group does without the effect. A member or group with opacity below 100% or a blending mode other than Normal now holds its members' surfaces in one group with that opacity and mode. --- crates/effects/src/revolve.rs | 60 ++++++++++++++----------- crates/effects/src/solids.rs | 39 +++++++++------- crates/effects/src/solids/tests_mesh.rs | 2 +- 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 0a8479d61..99a6e053e 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -2,12 +2,12 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, Rect}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; +use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -431,33 +431,34 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } -/// The members' `surfaces` in order, from the blending mode at `level` of each one's -/// `Profile::blends` inward: the surfaces of consecutive members that share a group with a mode -/// other than Normal go in one group with that mode, so each mode applies once, at its own level. -fn blended(id: NodeId, surfaces: Vec<(&[(usize, BlendMode)], Node)>, level: usize) -> Vec> { - fn close(id: NodeId, run: &mut Vec<(&[(usize, BlendMode)], Node)>, key: Option<(usize, BlendMode)>, level: usize, out: &mut Vec>) { - if let Some((_, blend)) = key { - let mut group = Node::group(id, blended(id, std::mem::take(run), level + 1)); - group.blend = blend; +/// The members' `surfaces` in order, from the [`Scope`] at `level` of each one's `Profile::scopes` +/// inward: the surfaces of consecutive members that share a scope go in one group with its opacity +/// and blending mode, so each applies once, to the members it holds together. +fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option, level: usize, out: &mut Vec>) { + if let Some(scope) = scope { + let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); + group.opacity = scope.opacity; + group.blend = scope.blend; out.push(Arc::new(group)); } } let mut out = vec![]; let mut run = vec![]; - let mut key = None; - for (blends, s) in surfaces { - let at = blends.get(level).copied(); - if at != key { - close(id, &mut run, key, level, &mut out); - key = at; + let mut scope = None; + for (scopes, s) in surfaces { + let at = scopes.get(level).copied(); + if at.map(|a| a.key) != scope.map(|s: Scope| s.key) { + close(id, &mut run, scope, level, &mut out); + scope = at; } - if key.is_some() { - run.push((blends, s)); + if scope.is_some() { + run.push((scopes, s)); } else { out.push(Arc::new(s)); } } - close(id, &mut run, key, level, &mut out); + close(id, &mut run, scope, level, &mut out); out } @@ -477,13 +478,13 @@ fn group_art(n: &Node, expand: bool) -> Option { let mut opaque = true; for s in &shapes { let Some(item) = surface_item(&s.items) else { continue }; + // The opacity and blending of the member and the groups it is in apply through its scopes. let (paint, opacity, blend) = paint_of(item); - let opacity = opacity * s.opacity; - opaque &= opacity >= 1.0 && blend == Default::default() && s.blends.is_empty(); - // A member with non-normal blending keeps every face, as a blended object does. - let expand = expand && s.blends.is_empty(); + opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty(); + // A member with transparency of its own keeps every face, as a transparent object does. + let expand = expand && s.scopes.is_empty(); let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; - surfaces.push((depths, art, s.blends.as_slice())); + surfaces.push((depths, art, s.scopes.as_slice())); } if surfaces.is_empty() { return None; @@ -500,7 +501,7 @@ fn group_art(n: &Node, expand: bool) -> Option { faces.sort_by(|a, b| a.0.total_cmp(&b.0)); vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] } else { - blended(n.id, surfaces.into_iter().map(|(_, s, blends)| (blends, s)).collect(), 0) + scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, s)).collect(), 0) }; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -752,6 +753,7 @@ mod tests { #[test] fn blending_modes_apply_once_to_the_member_or_group_that_has_them() { + use vectorcraft_doc::color::BlendMode; let blend = |n: Arc, mode: BlendMode| { let mut n = (*n).clone(); n.blend = mode; @@ -775,6 +777,14 @@ mod tests { assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed"); let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]); assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face"); + // A nested group's opacity applies once, to its members' surfaces together. + let mut faded = Node::group(NodeId(6), vec![member(3, 40.0), member(4, 45.0)]); + faded.opacity = 0.5; + let art = revolve_art(&group_with(json!({}), vec![member(1, 0.0), Arc::new(faded)])).expect("revolved"); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let opacities = |n: &Node| n.children().unwrap().iter().map(|c| c.opacity).collect::>(); + assert_eq!(children.iter().map(|c| c.opacity).collect::>(), [1.0, 0.5]); + assert_eq!(opacities(&children[1]), [1.0, 1.0], "the faded group's members, together"); } #[test] diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index c4b55fd5e..ab119192c 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -224,17 +224,26 @@ pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRul }) } +/// A group in a 3D effect's group, or a member of it, whose opacity is below 100% or whose blending +/// mode is not Normal. The members of one group share its `key`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Scope { + pub(crate) key: usize, + pub(crate) opacity: f32, + pub(crate) blend: BlendMode, +} + /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint -/// it, the opacity of the objects it is in (1 for the object with the effect itself) and their -/// blending modes other than Normal (none for the object itself). `blends` runs from the outermost -/// group in the effect's group down to the member itself, each with a key that the members of -/// one group share. Group Revolve draws the surfaces in those modes. +/// it, and the opacity of the objects it is in multiplied together (1 for the object with the +/// effect itself). `scopes` lists those objects with transparency of their own ([`Scope`]), from +/// the outermost group down to the member itself (none for the object itself). Group Revolve +/// applies each once, to the surfaces of the members it holds together. pub(crate) struct Profile { pub(crate) path: PathData, pub(crate) rule: FillRule, pub(crate) items: Vec, pub(crate) opacity: f32, - pub(crate) blends: Vec<(usize, BlendMode)>, + pub(crate) scopes: Vec, } /// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? @@ -265,20 +274,20 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { out: Vec, keys: usize, } - fn walk(n: &Node, opacity: f32, blends: &[(usize, BlendMode)], w: &mut Walk) -> Result<(), String> { + fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> { let label = w.label; for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { continue; } let opacity = opacity * c.opacity; - let mut blends = blends.to_vec(); - if c.blend != BlendMode::Normal { + let mut scopes = scopes.to_vec(); + if c.blend != BlendMode::Normal || c.opacity < 1.0 { w.keys += 1; - blends.push((w.keys, c.blend)); + scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend }); } if is_container(c) { - walk(c, opacity, &blends, w)?; + walk(c, opacity, &scopes, w)?; continue; } let Some((path, rule)) = profile(c) else { @@ -296,7 +305,7 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { } let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c)); if path.bounds().is_some() { - w.out.push(Profile { path, rule, items: solid_paints(c), opacity, blends }); + w.out.push(Profile { path, rule, items: solid_paints(c), opacity, scopes }); } } Ok(()) @@ -539,7 +548,7 @@ pub fn solid_art(n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, blends: vec![] }])?); + children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -555,7 +564,7 @@ pub fn solid_art(n: &Node) -> Option { for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, blends: vec![] }])? { + for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? { Arc::make_mut(&mut art).appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); children.push(art); @@ -718,14 +727,14 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result Date: Mon, 9 Nov 2026 18:35:41 -0800 Subject: [PATCH 15/48] =?UTF-8?q?Scripts:=20File=20=E2=80=BA=20Scripts=20?= =?UTF-8?q?=E2=80=BA=20Other=20Script=E2=80=A6=20runs=20JavaScript=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Scripts › Other Script… picks a .js or .jsx file, shows a warning that can be turned off in Preferences › General, and runs the script against the open documents through the `vc` object. The new `script.run` and `script.stop` commands run and stop a script over MCP. - A run is one transaction. Each document it changed gets one undo step named Run Script. An uncaught error, a limit or a stop puts every document, the paint, the clipboard, menu state and preferences back. - A command that fails inside a run, or whose result is too large to reach the script, is undone and throws an error the script can catch. - Files a command wrote stay on disk and the report names them, also when the run is stopped from the quit question. - While a script runs, other commands that change anything fail with a busy error; MCP reports it as JSON-RPC error -32003. - In the app the run advances one slice per frame with a Stop button in the status bar. A run that adds a great many objects asks whether to continue; Stop and Escape end it. - Commands that change state outside the documents, such as prefs.*, plugin.* and file.place.queue, are refused to scripts. Docs: docs/mcp.md, docs/gaps.md, docs/roadmap.md, docs/target-app-parity.md. --- apps/vectorcraft-web/Cargo.toml | 2 +- apps/vectorcraft-web/src/web.rs | 33 +- crates/doc/src/document.rs | 5 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/cmd/recovery.rs | 34 +- crates/engine/src/cmd/scripts.rs | 134 ++- crates/engine/src/command_sets.rs | 1 + crates/engine/src/error.rs | 6 + crates/engine/src/file_access.rs | 35 +- crates/engine/src/lib.rs | 18 +- crates/engine/src/link_watch.rs | 13 +- crates/engine/src/prefs.rs | 3 + crates/engine/src/script/mod.rs | 1091 ++++++++++++++++++++++ crates/engine/src/session/documents.rs | 7 + crates/engine/src/session/edit.rs | 9 + crates/engine/src/session/execute.rs | 8 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/linkwatch.rs | 20 + crates/engine/src/tests/script_run.rs | 728 +++++++++++++++ crates/engine/src/tooling.rs | 9 + crates/js/src/builtins.rs | 2 +- crates/js/src/heap.rs | 4 + crates/js/src/host.rs | 351 +++++++ crates/js/src/host_tests.rs | 277 ++++++ crates/js/src/interp.rs | 55 +- crates/js/src/lib.rs | 6 +- crates/js/src/realm.rs | 130 ++- crates/mcp/src/server.rs | 9 +- crates/mcp/src/tests/concurrency.rs | 17 + crates/mcp/src/tools.rs | 5 + crates/ui-egui/src/app.rs | 32 +- crates/ui-egui/src/chrome/status_bar.rs | 18 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/dialogs.rs | 8 + crates/ui-egui/src/dialogs/script_run.rs | 147 +++ crates/ui-egui/src/i18n/bg.tsv | 9 + crates/ui-egui/src/i18n/bs.tsv | 9 + crates/ui-egui/src/i18n/ca.tsv | 21 + crates/ui-egui/src/i18n/cs.tsv | 21 + crates/ui-egui/src/i18n/da.tsv | 9 + crates/ui-egui/src/i18n/de.tsv | 21 + crates/ui-egui/src/i18n/el.tsv | 21 + crates/ui-egui/src/i18n/es.tsv | 21 + crates/ui-egui/src/i18n/fi.tsv | 21 + crates/ui-egui/src/i18n/fr.tsv | 21 + crates/ui-egui/src/i18n/hi.tsv | 21 + crates/ui-egui/src/i18n/hu.tsv | 21 + crates/ui-egui/src/i18n/id.tsv | 9 + crates/ui-egui/src/i18n/it.tsv | 21 + crates/ui-egui/src/i18n/ja.tsv | 21 + crates/ui-egui/src/i18n/ka.tsv | 21 + crates/ui-egui/src/i18n/ko.tsv | 21 + crates/ui-egui/src/i18n/nl.tsv | 9 + crates/ui-egui/src/i18n/pl.tsv | 21 + crates/ui-egui/src/i18n/pt-br.tsv | 21 + crates/ui-egui/src/i18n/ru.tsv | 21 + crates/ui-egui/src/i18n/sv.tsv | 21 + crates/ui-egui/src/i18n/tr.tsv | 21 + crates/ui-egui/src/i18n/uk.tsv | 21 + crates/ui-egui/src/i18n/vi.tsv | 21 + crates/ui-egui/src/i18n/zh-hans.tsv | 21 + crates/ui-egui/src/i18n/zh-hant.tsv | 21 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/tree.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/picks.rs | 1 + crates/ui-egui/src/script_runs.rs | 44 + crates/ui-egui/src/services.rs | 5 + crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/script_check.rs | 2 +- crates/ui-egui/src/tests/script_run.rs | 283 ++++++ docs/gaps.md | 5 +- docs/mcp.md | 68 ++ docs/roadmap.md | 2 +- docs/target-app-parity.md | 1 + 77 files changed, 4093 insertions(+), 58 deletions(-) create mode 100644 crates/engine/src/script/mod.rs create mode 100644 crates/engine/src/tests/script_run.rs create mode 100644 crates/js/src/host.rs create mode 100644 crates/js/src/host_tests.rs create mode 100644 crates/ui-egui/src/dialogs/script_run.rs create mode 100644 crates/ui-egui/src/script_runs.rs create mode 100644 crates/ui-egui/src/tests/script_run.rs diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 6e4dbd7f0..eb35a110c 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -26,7 +26,7 @@ log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } -rfd = { workspace = true } +rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen = "0.2" serde_json = { workspace = true } wasm-bindgen-futures = "0.4" diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 60682d698..b4ca434d9 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -9,6 +9,9 @@ use vectorcraft_engine::Session; use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address}; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore}; +use vectorcraft_engine::cmd::scripts::RUN_EXTS; +use vectorcraft_engine::script::MAX_SOURCE_BYTES; +use vectorcraft_ui_egui::dialogs::script_run::ScriptInbox; use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::i18n; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; @@ -47,6 +50,7 @@ struct Host { runner: eframe::WebRunner, inbox: Inbox, place_inbox: PlaceInbox, + script_inbox: ScriptInbox, drag: DragPos, pasted: Rc>, /// Pen and finger input read from the page's Pointer Events (`crate::pencil`). @@ -75,6 +79,7 @@ pub fn start() { runner: eframe::WebRunner::new(), inbox: Arc::default(), place_inbox: Arc::default(), + script_inbox: Arc::default(), drag: DragPos::default(), pasted: Rc::default(), pointers: SharedPointers::default(), @@ -120,7 +125,8 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { // The old services asked the old context for frames. @@ -425,12 +431,35 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option, stylus: StylusFeed) -> Services { +fn services( + inbox: Inbox, + place_inbox: PlaceInbox, + script_inbox: ScriptInbox, + ctx: egui::Context, + locks: Option, + stylus: StylusFeed, +) -> Services { let open_inbox = inbox.clone(); let picked = place_inbox.clone(); let place_ctx = ctx.clone(); let screen_color = eye_dropper(ctx.clone()); + let (script_picked, script_ctx) = (script_inbox.clone(), ctx.clone()); Services { + // File › Scripts › Other Script…: the picked file goes to the warning before it runs. + script_async: Some(Box::new(move || { + let inbox = script_picked.clone(); + let ctx = script_ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let filter = vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", "Scripts"); + let Some(file) = rfd::AsyncFileDialog::new().add_filter(filter, RUN_EXTS).pick_file().await else { + return; + }; + let bytes = if file.inner().size() > MAX_SOURCE_BYTES as f64 { None } else { Some(file.read().await) }; + inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + ctx.request_repaint(); + }); + })), + script_inbox: Some(script_inbox), // File → Place…: the picked files go to the Place dialog. place_async: Some(Box::new(move || { let inbox = picked.clone(); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index ebd27fd87..2a2f8a8d7 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -276,6 +276,11 @@ impl Document { self.next_id } + /// Makes the next id at least `id`, so ids below it are not given out again. + pub fn raise_next_id(&mut self, id: u64) { + self.next_id = self.next_id.max(id); + } + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). pub fn fix_next_id(&mut self) { // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 3394585bb..e974e5542 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -40,6 +40,7 @@ vectorcraft-metafile = { workspace = true } vectorcraft-plugins = { workspace = true } vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } +vectorcraft-js = { workspace = true } image = { workspace = true } jxl-oxide = { workspace = true } jxl-color = { workspace = true } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f3cf6416f..f85c74198 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -140,6 +140,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("disableAutoAddDelete", "General", "Options", "Disable Auto Add/Delete", bool), p!("usePreciseCursors", "General", "Options", "Use Precise Cursors", bool), p!("showToolTips", "General", "Options", "Show Tool Tips", bool), + p!("showExternalScriptWarning", "General", "Options", "Show External Script Warning", bool), p!("antiAliasedArtwork", "General", "Options", "Anti-aliased Artwork", bool), p!("selectSameTintPercent", "General", "Options", "Select Same Tint %", bool), p!("showHomeScreen", "General", "Options", "Show The Home Screen When No Documents Are Open", bool), diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index a06597ac8..4cf070c6a 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -766,11 +766,14 @@ pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> { return Some("unchanged since its last recovery copy"); } if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS { - return Some("complex document (Data Recovery is off for complex documents)"); + return Some(COMPLEX_SKIP); } None } +/// Why a complex document with unsaved changes gets no copy. +const COMPLEX_SKIP: &str = "complex document (Data Recovery is off for complex documents)"; + /// A file name part from a title: letters, digits, `-`, `_` and spaces (others as `-`). fn safe_stem(title: &str) -> String { let stem: String = fileio::file_stem(title).chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ') { c } else { '-' }).collect(); @@ -866,8 +869,35 @@ fn remove_copy(s: &Session, file: &str) { } } -/// Document `uid` was saved, reverted or closed: its recovery copy goes. +/// Closed document `st`'s recovery copy goes. +pub(crate) fn forget_closed(s: &Session, st: &crate::DocState) { + if let Some(copy) = &st.recovery { + remove_copy(s, ©.file); + } +} + +/// Writes a copy of every document that needs one now. An error when a document with unsaved +/// changes would be left without a current copy. +pub(crate) fn save_copies(s: &mut Session) -> Result<()> { + let store = need_store(s, "script.run")?; + let (jobs, _) = jobs(s, &store)?; + let uncopied = s.documents().iter().any(|st| skip_reason(s, st) == Some(COMPLEX_SKIP)); + if uncopied { + return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into())); + } + for j in jobs { + j.write(store.as_ref()).map_err(EngineError::Other)?; + j.finish(s); + } + Ok(()) +} + +/// Document `uid` was saved, reverted or closed: its recovery copy goes. While a script runs the +/// run's commit settles that. pub fn forget(s: &mut Session, uid: u64) { + if s.script_in_run { + return; + } let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return }; remove_copy(s, ©.file); } diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 45f267048..692de92f3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -11,21 +11,135 @@ use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report}; /// The ids a reply can hold, for the windows that show it. pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS}; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param}; +use crate::script; /// The extensions of the scripts File › Open shows a check for. pub const EXTS: &[&str] = &["jsx", "jsxinc", "jsxbin"]; +/// The extensions of the scripts `script.run` takes as a file, in any case. +pub const RUN_EXTS: &[&str] = &["js", "jsx"]; + +/// Does `path` name a file `script.run` takes? +pub fn runs(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| RUN_EXTS.iter().any(|x| e.eq_ignore_ascii_case(x))) +} + pub fn specs() -> Vec { - vec![cmd!( - query "script.check", - "Check Script", - [], - None, - "{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)", - always, - check - )] + vec![ + cmd!( + query "script.run", + "Run Script", + [], + None, + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: {} (no prompts exist yet: only an empty object is taken), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report; vc.args is args; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport", + always, + run + ), + cmd!( + query "script.stop", + "Stop Script", + [], + None, + "{} stop the running script, which puts every document back as it was before the run; asking again while it stops is harmless → {stopped: whether a script was running}", + always, + stop + ), + cmd!( + query "script.check", + "Check Script", + [], + None, + "{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)", + always, + check + ), + ] +} + +/// A refusal of `script.run`'s parameters, which only a program's own call can give. +fn param(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: "script.run".into(), msg: msg.into() } +} + +/// `maxFuel`: a whole number from 1 to [`script::MAX_FUEL`], or the default when omitted. +fn max_fuel(p: &Value) -> Result { + let Some(v) = p.get("maxFuel") else { return Ok(script::DEFAULT_FUEL) }; + v.as_u64() + .filter(|n| (1..=script::MAX_FUEL).contains(n)) + .ok_or_else(|| param(format!("`maxFuel` must be a whole number from 1 to {}", script::MAX_FUEL))) +} + +/// The text of a script read as bytes: a byte order mark is left out, and compiled scripts and +/// bytes that are not UTF-8 are refused. +fn script_text(bytes: &[u8]) -> Result { + if bytes.starts_with(b"@JSXBIN@") { + return Err(EngineError::Other(COMPILED.to_string())); + } + let text = std::str::from_utf8(bytes).map_err(|_| EngineError::Other(NOT_TEXT.to_string()))?; + Ok(text.strip_prefix('\u{feff}').unwrap_or(text).to_string()) +} + +const COMPILED: &str = "this is a compiled script, which cannot be run; use its source text"; +const NOT_TEXT: &str = "the script is not UTF-8 text"; +/// The error for a script over [`script::MAX_SOURCE_BYTES`]. +pub const TOO_LARGE: &str = "the script is too large to run"; + +fn run(s: &mut Session, p: &Value) -> Result { + let optional_str = |key: &str| match p.get(key) { + None => Ok(None), + Some(Value::String(t)) => Ok(Some(t.as_str())), + Some(_) => Err(param(format!("`{key}` must be a string"))), + }; + let (path, source, given_name) = (optional_str("path")?, optional_str("source")?, optional_str("name")?); + let args = match p.get("args") { + None => serde_json::Map::new(), + Some(Value::Object(o)) => o.clone(), + Some(_) => return Err(param("`args` must be an object")), + }; + if serde_json::to_vec(&args).map_or(true, |b| b.len() > script::MAX_ARGS_BYTES) { + return Err(param("`args` is larger than 1 MiB")); + } + match p.get("answers") { + None => {} + Some(Value::Object(o)) if o.is_empty() => {} + Some(_) => return Err(param("`answers` must be an empty object: scripts cannot ask questions yet")), + } + let fuel = max_fuel(p)?; + let text = match (path, source) { + (Some(""), None) => return Err(param("`path` is empty")), + (Some(path), None) => { + if path.to_ascii_lowercase().ends_with(".jsxbin") { + return Err(EngineError::Other(COMPILED.to_string())); + } + if !runs(path) { + return Err(param("`path` must name a .js or .jsx file")); + } + // One byte more than a script may have shows that the file is too large. + let bytes = super::fileio::read_file_head(path, script::MAX_SOURCE_BYTES + 1)?; + if bytes.len() > script::MAX_SOURCE_BYTES { + return Err(EngineError::Other(TOO_LARGE.to_string())); + } + script_text(&bytes)? + } + (None, Some(source)) => { + if source.len() > script::MAX_SOURCE_BYTES { + return Err(EngineError::Other(TOO_LARGE.to_string())); + } + script_text(source.as_bytes())? + } + _ => return Err(param("give exactly one of `path` and `source`")), + }; + let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); + s.script_start(script::Request { name: name.clone(), source: text, args, fuel })?; + if s.script_frames { + return Ok(json!({"running": true, "name": name})); + } + s.script_run_to_end() +} + +fn stop(s: &mut Session, _: &Value) -> Result { + Ok(json!({"stopped": s.script_request_stop()})) } /// The folder a script is in, which its included files are read from. diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 38ce14cab..77068d125 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -83,6 +83,7 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { /// Instances and Lock Guides change the selection, and the proof views, the preferences and added /// fonts give the documents new revisions (they draw again, and type lays out again). const WAIT_FOR_TOOL_EDIT: &[&str] = &[ + "script.run", "command.batch", "document.find", "symbol.selectInstances", diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 8d939db77..d32fda613 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -1,5 +1,8 @@ use super::{NodeId, cancel}; +/// The text of [`EngineError::Busy`], which ends every error a frontend reports for it. +pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish"; + #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] @@ -20,6 +23,9 @@ pub enum EngineError { /// The caller asked for the command to stop ([`cancel`]). It changed nothing. #[error("cancelled; nothing was changed")] Cancelled, + /// A running script holds the documents until it ends. Nothing was changed. + #[error("{}", SCRIPT_BUSY)] + Busy, /// A bug: the command panicked. The document was rolled back to its state before the command. #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] Internal { cmd: String, msg: String }, diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 0565a9115..cdea7ac5b 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -208,7 +208,40 @@ pub fn check_read(path: &str) -> Result<(), String> { /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { - current().map_or(Ok(()), |r| r.check(path, Access::Write)) + current().map_or(Ok(()), |r| r.check(path, Access::Write))?; + WRITES.with(|w| { + if let Some(paths) = w.borrow_mut().as_mut() + && !paths.iter().any(|(p, _)| p == path) + { + paths.push((path.to_string(), stamp(path))); + } + }); + Ok(()) +} + +/// A file's modification time and length; `None` when there is no file at `path`. +type Stamp = Option<(Option, u64)>; + +fn stamp(path: &str) -> Stamp { + std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file).map(|m| (m.modified().ok(), m.len())) +} + +thread_local! { + /// The paths [`check_write`] allowed on this thread while they are being recorded, each with + /// its file's stamp at that moment. + static WRITES: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Starts recording the paths [`check_write`] allows on this thread. +pub(crate) fn start_recording_writes() { + WRITES.with(|w| *w.borrow_mut() = Some(Vec::new())); +} + +/// Ends recording → the recorded paths whose file exists now: after a command that `succeeded`, +/// all of them; after one that failed, those made or changed since they were allowed. +pub(crate) fn finish_recording_writes(succeeded: bool) -> Vec { + let recorded = WRITES.with(|w| w.borrow_mut().take()).unwrap_or_default(); + recorded.into_iter().filter(|(path, before)| stamp(path).is_some_and(|now| succeeded || Some(now) != *before)).map(|(path, _)| path).collect() } /// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ffcd43019..bf8b5162d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -242,6 +242,21 @@ pub struct Session { pub(crate) screens_hand_off: Option>, /// Where Data Recovery keeps its copies ([`cmd::recovery`]). pub recovery: cmd::recovery::Recovery, + /// The running script's job, while there is one: the mutation lease ([`script`]). + pub(crate) script_key: Option, + /// Stops the running script at its next instruction; the same flag for every run. + pub(crate) script_stop: vectorcraft_js::Interrupt, + /// The report of the run that ended last. + pub(crate) script_report: Option, + /// `script.run` starts the job and returns, and the frontend advances it once per frame + /// ([`Session::script_pump`]). Off, `script.run` advances it to its end. + pub script_frames: bool, + /// Each open document's highest next id a run has seen, by [`DocState::uid`]. + pub(crate) script_high_water: std::collections::HashMap, + /// While a slice of a script runs in this session. + pub(crate) script_in_run: bool, + /// Write recovery copies of the unsaved documents before a run, and don't start without them. + pub script_copy_first: bool, /// While the active tool's actions run: their commands aren't "a command from outside the /// tool" ([`Session::after_command`]). pub(crate) in_tool_actions: bool, @@ -328,6 +343,7 @@ mod history; mod interaction; mod prefs; mod sanity; +pub mod script; mod session; #[cfg(test)] @@ -336,7 +352,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS; use command_sets::given_ids; pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit}; pub use doc_state::{DocState, OpenRows, RecalledSelection}; -pub use error::{EngineError, Result}; +pub use error::{EngineError, Result, SCRIPT_BUSY}; pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; pub(crate) use interaction::PreviewCopies; pub use interaction::{Interaction, LastTransform, UndoGroup, UndoScope}; diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 6cdf5c359..747ce9154 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -84,9 +84,9 @@ pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result { impl Session { /// Start stamping the active document's linked files on a worker thread, unless a look is - /// still running (or the build has no files to look at: the web app). + /// still running, a script is running (or the build has no files to look at: the web app). pub fn start_link_scan(&mut self) { - if self.link_scan.is_some() || cfg!(target_arch = "wasm32") { + if self.link_scan.is_some() || self.script_key.is_some() || cfg!(target_arch = "wasm32") { return; } let Some(st) = self.active() else { return }; @@ -115,10 +115,13 @@ impl Session { } } - /// Apply a finished look (`None` while there is none, or it is still running): see - /// [`Session::apply_link_stamps`]. A look whose worker ended without stamps is cleared, also - /// with `None`, so the next one can start. + /// Apply a finished look (`None` while there is none, it is still running, or a script is + /// running): see [`Session::apply_link_stamps`]. A look whose worker ended without stamps is + /// cleared, also with `None`, so the next one can start. pub fn poll_link_scan(&mut self) -> Option> { + if self.script_key.is_some() { + return None; + } self.link_scan.take_if(|scan| ended_without_stamps(scan, |ended| ended.load(Ordering::SeqCst))); let (uid, stamps) = self.link_scan.as_ref()?.0.lock().unwrap_or_else(PoisonError::into_inner).take()?; self.link_scan = None; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d3b034526..779f4b8da 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -31,6 +31,8 @@ pub struct Prefs { pub disable_auto_add_delete: bool, pub use_precise_cursors: bool, pub show_tool_tips: bool, + /// Other Script… warns before it runs a chosen script. + pub show_external_script_warning: bool, pub anti_aliased_artwork: bool, pub select_same_tint_percent: bool, pub show_home_screen: bool, @@ -364,6 +366,7 @@ impl Default for Prefs { disable_auto_add_delete: false, use_precise_cursors: false, show_tool_tips: true, + show_external_script_warning: true, anti_aliased_artwork: true, select_same_tint_percent: false, show_home_screen: true, diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs new file mode 100644 index 000000000..4b5380319 --- /dev/null +++ b/crates/engine/src/script/mod.rs @@ -0,0 +1,1091 @@ +//! Running a script: `script.run`, `script.stop` and the job behind them. +//! +//! A run works on its own copy of the open documents and the session state commands change (the +//! working copy). Between two slices of a run, the session holds the state as it was before the +//! run: what is drawn, what a read-only command returns and what Data Recovery copies never show a +//! script's unfinished work. Each slice swaps the working copy in, advances the interpreter by one +//! measured slice of fuel and swaps it out. A commit makes the working copy the session's state, +//! with one undo step for each document whose art changed. A rollback drops the working copy. +//! +//! While a job exists, [`Session::execute`] refuses any command that can edit, select, save or give +//! a document a new revision, and the tool events are refused the same way (the mutation lease). +//! The script's own commands run through [`Session::execute`] inside a slice, each with its own +//! parameter checks, enabled rule, undo history and journal entry, and a command that fails leaves +//! no trace of itself. Inside a run, `edit.undo` reaches only steps the script made. +//! +//! Files a script's commands saved or exported before it failed stay written, and the failure names +//! them. Ids given out in a run are never given out again for a document, also after an in-run +//! undo or a rollback. +//! +//! The values here are VectorCraft's own limits: [`MAX_SOURCE_BYTES`], [`DEFAULT_FUEL`], +//! [`MAX_FUEL`], [`SLICE_FUEL`], [`HEAP_BYTES`], [`MAX_HOST_CALLS`], [`GROWTH_OBJECTS`], +//! [`GROWTH_BYTES`], [`MAX_CONTINUES`], [`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], [`MAX_ARGS_BYTES`], +//! and the 64 levels, 16 MiB and 1,048,576 entries of [`vectorcraft_js::DataLimits`]'s default. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; + +use serde_json::{Map, Value, json}; +use vectorcraft_js::{ + Data, DataLimits, HostError, HostFunction, HostItem, Interrupt, LimitKind, Limits, Options, Progress, Realm, RunError, RunErrorKind, +}; + +use crate::{ + Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit, +}; + +/// The most bytes of source a run takes, as a file or as `source`. +pub const MAX_SOURCE_BYTES: usize = 8 << 20; +/// The instructions a run may execute when `maxFuel` is left out. +pub const DEFAULT_FUEL: u64 = 1_000_000_000; +/// The largest `maxFuel`. +pub const MAX_FUEL: u64 = 100_000_000_000; +/// The instructions one slice of a run executes before the interface draws again. +pub const SLICE_FUEL: u64 = 500_000; +/// The heap one run may use. +#[cfg(not(target_arch = "wasm32"))] +pub const HEAP_BYTES: usize = 256 << 20; +/// The heap one run may use in a wasm build. +#[cfg(target_arch = "wasm32")] +pub const HEAP_BYTES: usize = 128 << 20; +/// The most `vc.run` calls and getter reads of one run. +pub const MAX_HOST_CALLS: u64 = 2_000_000; +/// The objects a run may make before it is asked to continue or stop. +pub const GROWTH_OBJECTS: u64 = 100_000; +/// The bytes of new document data a run may keep before it is asked to continue or stop. +pub const GROWTH_BYTES: u64 = 512 << 20; +/// How many times a person may let a run go past its growth allowance; each time adds one more. +pub const MAX_CONTINUES: u32 = 8; +/// The most `vc.log` entries of one run. +pub const MAX_LOG_LINES: usize = 10_000; +/// The most bytes of `vc.log` entries of one run. +pub const MAX_LOG_BYTES: usize = 4 << 20; +/// The most bytes of `args` given to one run, as JSON. +pub const MAX_ARGS_BYTES: usize = 1 << 20; +/// The `vc.run` calls one slice makes before the interface draws again. +const SLICE_HOST_CALLS: u32 = 256; +/// The bytes that may cross between a script and the host in one run. +const MAX_BRIDGE_BYTES: u64 = 4 << 30; + +/// The version `vc.api.version` gives. +pub const API_VERSION: &str = "1.0"; +/// The features `vc.api.has` knows. +pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args"]; + +/// What the step of a run's one undo step is called. +pub const STEP_LABEL: &str = "Run Script"; + +/// `id`, as `vc.run` is given it, is one a script may not run: a command that would leave the run's +/// transaction or change something outside the documents that a rollback could not put back. +pub fn refused_to_scripts(id: &str) -> bool { + const PREFIXES: &[&str] = &[ + "script.", + "plugin.", + "prefs.", + "actionSet.", + "shortcutSet.", + "help.", + "print.", + "library.", + "importMatches.", + "edit.undoGroup.", + "file.recovery.", + "stroke.widthProfile.", + ]; + const IDS: &[&str] = &[ + "text.addFontFiles", + "text.findFontFiles", + "text.rescanFonts", + "links.findFiles", + "file.place.queue", + "color.loadProfile", + "color.removeProfile", + "file.print", + "view.proofSetup", + "view.proofColors", + "view.overprintPreview", + "view.separationsPreview", + ]; + let saved_preset = (id.contains("resets.") || id.contains(".preset.")) && !id.ends_with(".list") && !id.ends_with(".apply"); + id != "script.check" && (saved_preset || PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id)) +} + +/// The error for a script's command that [`refused_to_scripts`] names. +pub(crate) fn refused_text(id: &str) -> String { + format!("a script cannot run `{id}`") +} + +/// A request to run a script. +pub(crate) struct Request { + pub name: String, + pub source: String, + pub args: Map, + pub fuel: u64, +} + +thread_local! { + /// The running jobs of this thread's sessions. A job is held here, and not in its session, + /// because it holds the interpreter, which stays on the thread that started it. + static JOBS: RefCell> = RefCell::new(HashMap::new()); +} + +static NEXT_JOB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// A session's hold on its running job: while one exists the lease is held. Dropping it ends the job. +#[derive(Debug)] +pub struct JobKey(u64); + +impl Drop for JobKey { + fn drop(&mut self) { + let _ = JOBS.try_with(|jobs| { + if let Ok(mut jobs) = jobs.try_borrow_mut() { + jobs.remove(&self.0); + } + }); + } +} + +fn with_job(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { + JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.0).map(f)) +} + +/// The state a script changes that a rollback puts back. +struct World { + docs: Vec, + active: Option, + paint: PaintDefaults, + fill_active: bool, + draw_mode: DrawMode, + draw_inside: Option, + clipboard: Clipboard, + untitled_counter: u32, + menu: cmd::menucmds::MenuState, + last_solid: crate::Color, + last_gradient: crate::GradientPaint, + recent_colors: Vec, + pending_paint: Option, + choices: Choices, + journal: Journal, + /// The preferences when the run started. + start_prefs: crate::Prefs, +} + +/// The choices outside the documents that a script's commands can change: the preferences, the +/// global edit options, the recent URLs, the chosen gradient stop, freeform point and appearance +/// item, the Eyedropper's sample and the envelope defaults. +#[derive(Clone)] +struct Choices { + prefs: crate::Prefs, + eyedropper_sample: Option>, + global_edit_options: cmd::globaledit::Options, + recent_urls: Vec, + gradient_stop: Option<(usize, cmd::gradient::StopOwner)>, + freeform_point: Option<(usize, cmd::gradient::StopOwner)>, + active_appearance_item: Option, + envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, +} + +impl Choices { + fn of(s: &Session) -> Self { + Self { + prefs: s.prefs.clone(), + eyedropper_sample: s.eyedropper_sample.clone(), + global_edit_options: s.global_edit_options.clone(), + recent_urls: s.recent_urls.clone(), + gradient_stop: s.gradient_stop, + freeform_point: s.freeform_point, + active_appearance_item: s.active_appearance_item.clone(), + envelope_defaults: s.envelope_defaults, + } + } + + fn put(self, s: &mut Session) { + s.prefs = self.prefs; + s.eyedropper_sample = self.eyedropper_sample; + s.global_edit_options = self.global_edit_options; + s.recent_urls = self.recent_urls; + s.gradient_stop = self.gradient_stop; + s.freeform_point = self.freeform_point; + s.active_appearance_item = self.active_appearance_item; + s.envelope_defaults = self.envelope_defaults; + } + + fn swap(&mut self, s: &mut Session) { + use std::mem::swap; + swap(&mut self.prefs, &mut s.prefs); + swap(&mut self.eyedropper_sample, &mut s.eyedropper_sample); + swap(&mut self.global_edit_options, &mut s.global_edit_options); + swap(&mut self.recent_urls, &mut s.recent_urls); + swap(&mut self.gradient_stop, &mut s.gradient_stop); + swap(&mut self.freeform_point, &mut s.freeform_point); + swap(&mut self.active_appearance_item, &mut s.active_appearance_item); + swap(&mut self.envelope_defaults, &mut s.envelope_defaults); + } +} + +/// The preferences after a run: `current`, the session's, which the app may have changed while the +/// run went on, with each preference the run changed from `start` set to the run's value. +fn merged_prefs(start: &crate::Prefs, run: &crate::Prefs, current: &crate::Prefs) -> crate::Prefs { + let (Ok(Value::Object(start)), Ok(Value::Object(run)), Ok(Value::Object(mut out))) = + (serde_json::to_value(start), serde_json::to_value(run), serde_json::to_value(current)) + else { + return current.clone(); + }; + for (key, value) in run { + if start.get(&key) != Some(&value) { + out.insert(key, value); + } + } + serde_json::from_value(Value::Object(out)).unwrap_or_else(|_| current.clone()) +} + +impl World { + /// A working copy of the session's state: its documents, each with an undo history of its own + /// that starts empty, and an empty journal. + fn working(s: &Session) -> Self { + let mut docs = s.docs.clone(); + for st in &mut docs { + st.history.undo.clear(); + st.history.redo.clear(); + } + Self { + docs, + active: s.active, + paint: s.paint.clone(), + fill_active: s.fill_active, + draw_mode: s.draw_mode, + draw_inside: s.draw_inside, + clipboard: s.clipboard.clone(), + untitled_counter: s.untitled_counter, + menu: s.menu.clone(), + last_solid: s.last_solid, + last_gradient: s.last_gradient.clone(), + recent_colors: s.recent_colors.clone(), + pending_paint: s.pending_paint.clone(), + choices: Choices::of(s), + journal: Journal::default(), + start_prefs: s.prefs.clone(), + } + } + + /// Exchanges this state with the session's. + fn swap(&mut self, s: &mut Session) { + use std::mem::swap; + swap(&mut self.docs, &mut s.docs); + swap(&mut self.active, &mut s.active); + swap(&mut self.paint, &mut s.paint); + swap(&mut self.fill_active, &mut s.fill_active); + swap(&mut self.draw_mode, &mut s.draw_mode); + swap(&mut self.draw_inside, &mut s.draw_inside); + swap(&mut self.clipboard, &mut s.clipboard); + swap(&mut self.untitled_counter, &mut s.untitled_counter); + swap(&mut self.menu, &mut s.menu); + swap(&mut self.last_solid, &mut s.last_solid); + swap(&mut self.last_gradient, &mut s.last_gradient); + swap(&mut self.recent_colors, &mut s.recent_colors); + swap(&mut self.pending_paint, &mut s.pending_paint); + self.choices.swap(s); + swap(&mut self.journal, &mut s.journal); + } +} + +/// The commands that can change the clipboard and still fail, and that do not put it back +/// themselves. The clipboard can hold a great deal, so a savepoint copies it only for these. +const CAN_FAIL_AFTER_COPYING: &[&str] = &["action.batch", "edit.cut"]; + +/// What one command may need to put back when it fails inside a run: the state of [`World`] the +/// command can reach. +pub(crate) struct Savepoint { + docs: Vec, + active: Option, + paint: PaintDefaults, + fill_active: bool, + draw_mode: DrawMode, + draw_inside: Option, + clipboard: Option, + untitled_counter: u32, + menu: cmd::menucmds::MenuState, + last_solid: crate::Color, + last_gradient: crate::GradientPaint, + recent_colors: Vec, + pending_paint: Option, + choices: Choices, + journal: usize, +} + +impl Savepoint { + pub(crate) fn take(s: &Session, id: &str) -> Self { + Self { + docs: s.docs.clone(), + active: s.active, + paint: s.paint.clone(), + fill_active: s.fill_active, + draw_mode: s.draw_mode, + draw_inside: s.draw_inside, + clipboard: CAN_FAIL_AFTER_COPYING.contains(&id).then(|| s.clipboard.clone()), + untitled_counter: s.untitled_counter, + menu: s.menu.clone(), + last_solid: s.last_solid, + last_gradient: s.last_gradient.clone(), + recent_colors: s.recent_colors.clone(), + pending_paint: s.pending_paint.clone(), + choices: Choices::of(s), + journal: s.journal.len(), + } + } + + pub(crate) fn restore(self, s: &mut Session) { + s.docs = self.docs; + s.active = self.active; + s.paint = self.paint; + s.fill_active = self.fill_active; + s.draw_mode = self.draw_mode; + s.draw_inside = self.draw_inside; + if let Some(clipboard) = self.clipboard { + s.clipboard = clipboard; + } + s.untitled_counter = self.untitled_counter; + s.menu = self.menu; + s.last_solid = self.last_solid; + s.last_gradient = self.last_gradient; + s.recent_colors = self.recent_colors; + s.pending_paint = self.pending_paint; + self.choices.put(s); + s.journal.truncate(self.journal); + } +} + +/// The counters and the session a script's host functions share. Between slices `session` holds a +/// spare session; during a slice it holds the real one, because the interpreter cannot be given a +/// borrow of it. +struct Host { + session: Rc>, + pause: Rc>, + /// The run is sliced by frames, so the person at the computer can be asked to continue. + framed: bool, + calls: Cell, + calls_this_slice: Cell, + objects: Cell, + bytes_kept: Cell, + bridge: Cell, + allowance_objects: Cell, + allowance_bytes: Cell, + continues: Cell, + /// Past the growth allowance, waiting for the person's answer. + waiting: Cell, + log: RefCell>, + log_bytes: Cell, + written: RefCell>, +} + +impl Host { + fn over(&self, limit: &str) -> HostError { + HostError::Abort(limit.to_string()) + } + + fn charge_call(&self, bytes: u64) -> std::result::Result<(), HostError> { + let calls = self.calls.get() + 1; + self.calls.set(calls); + if calls > MAX_HOST_CALLS { + return Err(self.over(OVER_CALLS)); + } + let bridge = self.bridge.get().saturating_add(bytes); + self.bridge.set(bridge); + if bridge > MAX_BRIDGE_BYTES { + return Err(self.over(OVER_BRIDGE)); + } + let n = self.calls_this_slice.get() + 1; + self.calls_this_slice.set(n); + if n >= SLICE_HOST_CALLS { + self.pause.set(true); + } + Ok(()) + } + + /// Counts what a command added to the documents, past which the person is asked or, without + /// anyone to ask, the run ends. A run that goes on past twice its allowance within one slice + /// ends too. + fn charge_growth(&self, objects: u64, bytes: u64) -> std::result::Result<(), HostError> { + self.objects.set(self.objects.get().saturating_add(objects)); + self.bytes_kept.set(self.bytes_kept.get().saturating_add(bytes)); + let (o, b) = (self.objects.get(), self.bytes_kept.get()); + let (limit_o, limit_b) = (self.allowance_objects.get(), self.allowance_bytes.get()); + if o <= limit_o && b <= limit_b { + return Ok(()); + } + let may_ask = self.framed && self.continues.get() < MAX_CONTINUES; + if !may_ask || o > limit_o.saturating_mul(2) || b > limit_b.saturating_mul(2) { + return Err(self.over(OVER_GROWTH)); + } + self.waiting.set(true); + self.pause.set(true); + Ok(()) + } +} + +/// A run in progress. +struct Job { + realm: Realm, + host: Rc, + /// Between slices the working copy; during the last slice's commit the state before the run. + world: World, + /// The documents open when the run began, by [`DocState::uid`]. + start_uids: Vec, + name: String, +} + +/// The `Data` for a JSON value, within `limits`. When it does not fit, the error is the place in +/// `v` where it stopped fitting, such as `.layers[2].name`, and empty for `v` itself. +pub(crate) fn to_data(v: &Value, limits: &DataLimits, depth: usize, used: &mut (usize, usize)) -> std::result::Result { + if depth > limits.depth { + return Err(String::new()); + } + used.0 = used.0.saturating_add(16); + used.1 = used.1.saturating_add(1); + if used.0 > limits.bytes || used.1 > limits.entries { + return Err(String::new()); + } + Ok(match v { + Value::Null => Data::Null, + Value::Bool(b) => Data::Bool(*b), + Value::Number(n) => Data::Number(n.as_f64().unwrap_or(f64::NAN)), + Value::String(s) => { + used.0 = used.0.saturating_add(s.len()); + if used.0 > limits.bytes { + return Err(String::new()); + } + Data::String(s.clone()) + } + Value::Array(a) => Data::Array( + a.iter() + .enumerate() + .map(|(i, x)| to_data(x, limits, depth + 1, used).map_err(|place| format!("[{i}]{place}"))) + .collect::>()?, + ), + Value::Object(o) => Data::Object( + o.iter() + .map(|(k, x)| { + used.0 = used.0.saturating_add(k.len()); + to_data(x, limits, depth + 1, used).map(|d| (k.clone(), d)).map_err(|place| format!(".{k}{place}")) + }) + .collect::>()?, + ), + }) +} + +/// The JSON for a value a script gave; a whole number within a double's exact range is an integer, +/// which is how commands read ids and counts. +fn to_json(d: &Data) -> Value { + match d { + Data::Undefined | Data::Null => Value::Null, + Data::Bool(b) => Value::Bool(*b), + Data::Number(n) => + { + #[allow(clippy::cast_possible_truncation)] + if n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_992.0 { + json!(*n as i64) + } else { + serde_json::Number::from_f64(*n).map_or(Value::Null, Value::Number) + } + } + Data::String(s) => Value::String(s.clone()), + Data::Array(a) => Value::Array(a.iter().map(to_json).collect()), + Data::Object(o) => Value::Object(o.iter().map(|(k, v)| (k.clone(), to_json(v))).collect()), + } +} + +/// Estimated bytes of `d`. +fn data_size(d: &Data) -> u64 { + match d { + Data::Undefined | Data::Null | Data::Bool(_) => 4, + Data::Number(_) => 8, + Data::String(s) => s.len() as u64 + 2, + Data::Array(a) => 8 + a.iter().map(data_size).sum::(), + Data::Object(o) => 8 + o.iter().map(|(k, v)| k.len() as u64 + 2 + data_size(v)).sum::(), + } +} + +/// Brings each open document's next id up to the highest one the session has given out for it. +fn raise_ids(s: &mut Session) { + let marks = std::mem::take(&mut s.script_high_water); + for st in &mut s.docs { + if let Some(&mark) = marks.get(&st.uid) + && mark > st.doc.peek_next_id() + { + st.change_unsaved(|d| Arc::make_mut(d).raise_next_id(mark)); + } + } + s.script_high_water = marks; +} + +fn note_ids(s: &mut Session) { + for st in &s.docs { + let mark = s.script_high_water.entry(st.uid).or_insert(0); + *mark = (*mark).max(st.doc.peek_next_id()); + } +} + +/// Runs command `id` inside a slice. A command that fails puts everything back it had done. +/// → its result, the objects it added, the bytes of document data it added and the state before +/// it, for a result that cannot cross to the script. +fn step(s: &mut Session, host: &Host, id: &str, params: &Value) -> Result<(Value, u64, u64, Option)> { + raise_ids(s); + let mutating = waits_for_tool_edit(id); + let save = mutating.then(|| Savepoint::take(s, id)); + let before: Vec<(u64, Arc, u64)> = + if mutating { s.docs.iter().map(|d| (d.uid, d.doc.clone(), d.doc.peek_next_id())).collect() } else { Vec::new() }; + crate::file_access::start_recording_writes(); + let r = s.execute(id, params); + // A file a command wrote before it failed is on disk all the same. + let mut written = host.written.borrow_mut(); + for path in crate::file_access::finish_recording_writes(r.is_ok()) { + if !written.contains(&path) { + written.push(path); + } + } + drop(written); + match r { + Ok(v) => { + note_ids(s); + let (mut objects, mut bytes) = (0, 0); + for st in &s.docs { + if let Some((_, d, ids)) = before.iter().find(|(uid, ..)| *uid == st.uid) + && !Arc::ptr_eq(d, &st.doc) + { + objects += st.doc.peek_next_id().saturating_sub(*ids); + bytes += d.retained_bytes_each(&st.doc).1 as u64; + } + } + Ok((v, objects, bytes, save)) + } + Err(e) => { + if let Some(save) = save { + save.restore(s); + } + Err(e) + } + } +} + +fn vc_run(host: &Rc, args: Vec) -> std::result::Result { + let Some(Data::String(id)) = args.first() else { return Err(HostError::Type(NO_COMMAND_ID.to_string())) }; + if args.len() > 2 { + return Err(HostError::Type(TOO_MANY_ARGUMENTS.to_string())); + } + let id = id.clone(); + let params = match args.get(1) { + None | Some(Data::Undefined) => json!({}), + Some(d @ Data::Object(_)) => to_json(d), + Some(_) => return Err(HostError::Type(PARAMS_NOT_OBJECT.to_string())), + }; + let bytes = args.iter().map(data_size).sum(); + host.charge_call(bytes)?; + let session = host.session.clone(); + let r = { + let mut s = session.borrow_mut(); + step(&mut s, host, &id, ¶ms) + }; + let (value, objects, added, save) = match r { + Ok(done) => done, + // A bug is not something a script can handle, and neither is a request to stop. + Err(e @ EngineError::Internal { .. }) => return Err(HostError::Abort(e.to_string())), + Err(EngineError::Cancelled) => return Err(HostError::Abort("canceled".into())), + Err(e) => return Err(HostError::Error(e.to_string())), + }; + let crossed = host.charge_growth(objects, added).and_then(|()| { + let data = to_data(&value, &result_limits(), 0, &mut (0, 0)) + .map_err(|place| HostError::Range(format!("vc.run: the result{place} is too large for a script")))?; + host.charge_call(data_size(&data))?; + Ok(data) + }); + // A command whose result does not reach the script is put back, like one that failed. + if crossed.is_err() + && let Some(save) = save + { + save.restore(&mut session.borrow_mut()); + } + crossed +} + +/// A bounded snapshot of the active document, or `null` with none open. A snapshot past the limits +/// of a crossing throws a `RangeError`. +fn vc_document(host: &Host) -> std::result::Result { + let s = host.session.borrow(); + let Some(st) = s.active() else { return Ok(Data::Null) }; + let ids: Vec = st.selection.objects.iter().take(1_000).map(|i| i.0).collect(); + let v = json!({ + "name": st.title(), + "path": st.path, + "modified": st.is_dirty(), + "artboards": st.doc.artboards.len(), + "layers": st.doc.layers.len(), + "selection": ids, + "selectionCount": st.selection.objects.len(), + }); + to_data(&v, &DataLimits::default(), 0, &mut (0, 0)).map_err(|place| HostError::Range(format!("vc.document{place} is too large for a script"))) +} + +/// Every command a script may run: its id, label and parameters. +fn vc_commands() -> Data { + Data::Array( + command_specs() + .iter() + .filter(|c| !refused_to_scripts(c.id)) + .map(|c| { + Data::Object(vec![ + ("id".into(), Data::String(c.id.into())), + ("label".into(), Data::String(c.label.into())), + ("params".into(), Data::String(c.params.into())), + ]) + }) + .collect(), + ) +} + +fn vc_log(host: &Host, args: Vec) -> std::result::Result { + let entry = args.first().unwrap_or(&Data::Undefined); + let bytes = data_size(entry) as usize; + if host.log.borrow().len() >= MAX_LOG_LINES || host.log_bytes.get().saturating_add(bytes) > MAX_LOG_BYTES { + return Err(HostError::Range(LOG_FULL.to_string())); + } + host.log_bytes.set(host.log_bytes.get() + bytes); + host.log.borrow_mut().push(to_json(entry)); + Ok(Data::Undefined) +} + +fn function(f: impl Fn(Vec) -> std::result::Result + 'static) -> HostFunction { + Rc::new(f) +} + +fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { + let run = { + let h = host.clone(); + function(move |a| vc_run(&h, a)) + }; + let document = { + let h = host.clone(); + function(move |_| { + h.charge_call(64)?; + vc_document(&h) + }) + }; + let commands = { + let h = host.clone(); + let list = Rc::new(vc_commands()); + let size = data_size(&list); + function(move |_| { + h.charge_call(size)?; + Ok((*list).clone()) + }) + }; + let log = { + let h = host.clone(); + function(move |a| { + h.charge_call(data_size(a.first().unwrap_or(&Data::Undefined)))?; + vc_log(&h, a) + }) + }; + let has = function(|a| Ok(Data::Bool(matches!(a.first(), Some(Data::String(f)) if API_FEATURES.contains(&f.as_str()))))); + vec![ + ("run".into(), HostItem::Function(run)), + ("document".into(), HostItem::Getter(document)), + ("commands".into(), HostItem::Getter(commands)), + ("log".into(), HostItem::Function(log)), + ("args".into(), HostItem::Value(args)), + ( + "api".into(), + HostItem::Object(vec![("version".into(), HostItem::Value(Data::String(API_VERSION.into()))), ("has".into(), HostItem::Function(has))]), + ), + ] +} + +/// A plain-words kind of a run's failure, as the report gives it. +fn kind_id(kind: &RunErrorKind) -> &'static str { + match kind { + RunErrorKind::Syntax => "syntax", + RunErrorKind::Thrown => "thrown", + RunErrorKind::Limit(LimitKind::Interrupted) => "stopped", + RunErrorKind::Limit(_) => "limit", + RunErrorKind::Internal => "internal", + } +} + +/// `message`, with what to do about a failure that comes from a script written for another program. +fn explained(kind: &RunErrorKind, message: &str, source: &str, line: u32) -> String { + const HOST_OBJECTS: &[&str] = + &["app", "$", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"]; + let names_host_object = + message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o))); + let directive = + *kind == RunErrorKind::Syntax && line > 0 && source.lines().nth(line as usize - 1).is_some_and(|l| l.trim_start().starts_with('#')); + if *kind == RunErrorKind::Thrown && names_host_object { + format!("{message} (scripts here use the `vc` object; objects other applications give their scripts are not available)") + } else if directive { + format!("{message} (preprocessor lines such as `#target` and `#include` are not read yet)") + } else { + message.to_string() + } +} + +impl Session { + /// Is a script running? While one is, the commands that change anything are refused. + pub fn script_running(&self) -> bool { + self.script_key.is_some() + } + + /// The flag that stops the script running in this session from any thread, at its next + /// instruction. + pub fn script_interrupt(&self) -> Interrupt { + self.script_stop.clone() + } + + /// The report of the run that ended last (`script.run`'s result, or its failure). + pub fn script_report(&self) -> Option<&Value> { + self.script_report.as_ref() + } + + /// The error text of the last run when it failed: its name, place, message and kind, and the + /// files it left written. + pub fn script_failure_text(&self) -> Option { + self.script_report.as_ref().filter(|r| r["ok"] == false).map(failure_text) + } + + /// Has the running script used up its growth allowance, so a person must answer? A run asked + /// to stop no longer waits: the next [`Session::script_pump`] ends it. + pub fn script_waiting(&self) -> bool { + !self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false) + } + + /// The name of the running script. + pub fn script_name(&self) -> Option { + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone())) + } + + /// Lets the run go past its growth allowance by one more allowance. + pub fn script_continue(&mut self) { + if let Some(k) = &self.script_key { + with_job(k, |j| { + let h = &j.host; + if h.waiting.replace(false) { + h.continues.set(h.continues.get() + 1); + h.allowance_objects.set(h.allowance_objects.get().saturating_add(growth_objects())); + h.allowance_bytes.set(h.allowance_bytes.get().saturating_add(GROWTH_BYTES)); + } + }); + } + } + + /// Asks the running script to stop; it is rolled back at the next slice. False with none running. + pub fn script_request_stop(&mut self) -> bool { + if self.script_key.is_none() { + return false; + } + self.script_stop.set(); + true + } + + /// Stops the running script at once and puts everything back, with the report of a stopped run + /// ([`Self::script_report`]). False with none running. + pub fn script_abandon(&mut self) -> bool { + let Some(key) = self.script_key.take() else { return false }; + let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { + drop(key); + raise_ids(self); + self.script_stop.clear(); + self.reset_tool(); + cmd::compositefonts::sync(self); + return true; + }; + drop(key); + let kind = LimitKind::Interrupted; + let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 }; + let _ = self.script_failed(job, &e); + true + } + + /// A script can't start while a document holds a drag in progress or an undo group, which its + /// one undo step could not take in. + fn settle_for_script(&mut self) -> Result<()> { + self.finish_tool_edit_later()?; + if self.active().is_some() { + cmd::settle_interaction(self)?; + } + if self.explicit_undo_group.is_some() || self.docs.iter().any(|d| d.undo_group.is_some() || d.interaction.is_some()) { + return Err(EngineError::Other(OPEN_EDIT.to_string())); + } + Ok(()) + } + + /// Begins a run of `request`. + pub(crate) fn script_start(&mut self, request: Request) -> Result<()> { + self.settle_for_script()?; + // In a build where a fault ends the program, every unsaved document has a copy first. + if self.script_copy_first && self.docs.iter().any(DocState::is_dirty) && cmd::recovery::save_copies(self).is_err() { + return Err(EngineError::Other(NO_RECOVERY_COPY.to_string())); + } + self.reset_tool(); + let open: Vec = self.docs.iter().map(|d| d.uid).collect(); + self.script_high_water.retain(|uid, _| open.contains(uid)); + self.script_stop.clear(); + self.script_report = None; + let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() }; + let options = Options { max_source_units: MAX_SOURCE_BYTES.saturating_mul(2), ..Options::default() }; + let mut realm = Realm::with_options(options, limits); + realm.share_interrupt(&self.script_stop); + realm.set_data_limits(DataLimits::default()); + let host = Rc::new(Host { + session: Rc::new(RefCell::new(Session::new())), + pause: realm.pause_flag(), + framed: self.script_frames, + calls: Cell::new(0), + calls_this_slice: Cell::new(0), + objects: Cell::new(0), + bytes_kept: Cell::new(0), + bridge: Cell::new(0), + allowance_objects: Cell::new(growth_objects()), + allowance_bytes: Cell::new(GROWTH_BYTES), + continues: Cell::new(0), + waiting: Cell::new(false), + log: RefCell::new(Vec::new()), + log_bytes: Cell::new(0), + written: RefCell::new(Vec::new()), + }); + let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) + .map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?; + let started = realm.define_host_object("vc", vc_object(&host, args)).and_then(|()| realm.start(&request.source)); + if let Err(e) = started { + let report = failure_report(&request.name, &e, &request.source, &host, 0); + let message = failure_text(&report); + self.script_report = Some(report); + return Err(EngineError::Other(message)); + } + let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name }; + let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + JOBS.with(|jobs| jobs.borrow_mut().insert(id, job)); + self.script_key = Some(JobKey(id)); + Ok(()) + } + + /// Advances the running script by one slice. `None` while it is still running; otherwise the + /// run has ended: its report, or the error that ended it, with the documents put back. + pub fn script_pump(&mut self) -> Option> { + let key = self.script_key.take()?; + let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { + return Some(Err(EngineError::Other(JOB_GONE.to_string()))); + }; + if job.host.waiting.get() && !self.script_stop.is_set() { + JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); + self.script_key = Some(key); + return None; + } + if vectorcraft_doc::cancel::check().is_err() { + self.script_stop.set(); + } + job.host.calls_this_slice.set(0); + let outcome = run_slice(self, &mut job); + match outcome { + Err(panic) => { + let e = RunError { kind: RunErrorKind::Internal, message: panic, line: 0, column: 0 }; + Some(self.script_failed(job, &e)) + } + Ok(Err(e)) => Some(self.script_failed(job, &e)), + Ok(Ok(Progress::Paused)) => { + JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); + self.script_key = Some(key); + None + } + Ok(Ok(Progress::Done(_))) => Some(self.script_commit(job)), + } + } + + fn script_failed(&mut self, job: Job, e: &RunError) -> Result { + let report = failure_report(&job.name, e, "", &job.host, job.realm.fuel_left()); + drop(job); + raise_ids(self); + self.script_stop.clear(); + self.reset_tool(); + cmd::compositefonts::sync(self); + let message = failure_text(&report); + self.script_report = Some(report); + Err(EngineError::Other(message)) + } + + /// Makes the working copy the session's state: one undo step for each document whose art + /// changed, nothing for the others. + fn script_commit(&mut self, mut job: Job) -> Result { + // The working copy goes into the session; `job.world` holds the state before the run. + job.world.swap(self); + self.prefs = merged_prefs(&job.world.start_prefs, &self.prefs, &job.world.choices.prefs); + note_ids(self); + raise_ids(self); + let start = std::mem::take(&mut job.world.docs); + for st in &self.docs { + let changed = start.iter().find(|b| b.uid == st.uid).is_none_or(|b| !st.same_content(&b.doc)); + if changed && (!crate::sanity::doc_sane(&st.doc, &st.selection) || st.doc.too_deep(None)) { + job.world.docs = start; + job.world.swap(self); + let e = RunError { kind: RunErrorKind::Internal, message: INVALID_RESULT.to_string(), line: 0, column: 0 }; + return self.script_failed(job, &e); + } + } + let mut changed_names = Vec::new(); + for st in &mut self.docs { + let Some(b) = start.iter().find(|b| b.uid == st.uid) else { + // A document the script opened or made starts with no undo steps. + st.history = crate::History { limit: st.history.limit, ..crate::History::default() }; + st.revision += 1; + changed_names.push(st.title()); + continue; + }; + let changed = !st.same_content(&b.doc); + st.history = b.history.clone(); + st.recovery.clone_from(&b.recovery); + if changed { + st.push_undo(STEP_LABEL.to_string(), b.doc.clone(), Arc::new(b.selection.clone())); + changed_names.push(st.title()); + } + if changed || st.revision != b.revision { + st.revision = st.revision.max(b.revision) + 1; + } + st.sync_new_art(); + } + // A document a frontend opened while the run went on stays open. + for st in start.iter().filter(|b| !job.start_uids.contains(&b.uid)) { + self.docs.push(st.clone()); + } + // A document the script closed is gone now, with its recovery copy. + for b in start.iter().filter(|b| job.start_uids.contains(&b.uid) && !self.docs.iter().any(|d| d.uid == b.uid)) { + cmd::recovery::forget_closed(self, b); + } + // A document the script saved has nothing left to recover. + cmd::recovery::forget_clean(self); + let entries: Vec<_> = self.journal.iter().cloned().collect(); + self.journal = std::mem::take(&mut job.world.journal); + for (id, p) in entries { + self.journal.push((id, p)); + } + self.script_stop.clear(); + self.reset_tool(); + cmd::compositefonts::sync(self); + let host = &job.host; + let report = json!({ + "ok": true, + "name": job.name, + "changed": changed_names, + "written": *host.written.borrow(), + "log": *host.log.borrow(), + "fuelUsed": job.realm.limits().fuel - job.realm.fuel_left(), + "hostCalls": host.calls.get(), + }); + self.script_report = Some(report.clone()); + Ok(report) + } + + /// Runs the running script until it ends. + pub(crate) fn script_run_to_end(&mut self) -> Result { + loop { + if let Some(result) = self.script_pump() { + return result; + } + if self.script_waiting() { + self.script_stop.set(); + } + } + } +} + +const OPEN_EDIT: &str = "a drag or an undo group is open; finish it before running a script"; +const INVALID_RESULT: &str = "the script left a document that cannot be kept"; +const JOB_GONE: &str = "the script's run is no longer available"; +// Text a script reads from the errors it catches, or that ends its run with its name and place. +const OVER_CALLS: &str = "the script made more host calls than one run may make"; +const OVER_BRIDGE: &str = "the script passed more data to and from commands than one run may pass"; +const OVER_GROWTH: &str = "the script added more objects and data to the documents than one run may add"; +const NO_COMMAND_ID: &str = "vc.run: give a command id, as a string"; +const TOO_MANY_ARGUMENTS: &str = "vc.run: takes a command id and an object of parameters"; +const PARAMS_NOT_OBJECT: &str = "vc.run: the parameters must be an object"; +const LOG_FULL: &str = "vc.log: the log of this run is full"; +const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the script was not run"; + +/// The report of a run that failed. +fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left: u64) -> Value { + json!({ + "ok": false, + "kind": kind_id(&e.kind), + "message": explained(&e.kind, &e.message, source, e.line), + "name": name, + "line": e.line, + "column": e.column, + "written": *host.written.borrow(), + "log": *host.log.borrow(), + "fuelLeft": fuel_left, + }) +} + +/// The text of a failed run's error. +fn failure_text(report: &Value) -> String { + let s = |k: &str| report[k].as_str().unwrap_or_default(); + let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); + let at = if line > 0 { format!(" line {line}, column {column}") } else { String::new() }; + let written = report["written"].as_array().map_or(0, Vec::len); + let kept = if written > 0 { + let paths: Vec<&str> = report["written"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + format!(" (these files stay written: {})", paths.join(", ")) + } else { + String::new() + }; + format!("{}:{at}: {} ({}){kept}", s("name"), s("message"), s("kind")) +} + +/// A run's slice: the working copy in, the real session lent to the host functions, one measured +/// slice run, and everything back. A panic in the interpreter is the outer `Err`. +fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result, String> { + let depth = std::mem::replace(&mut s.depth, 0); + job.world.swap(s); + s.script_in_run = true; + let cell = job.host.session.clone(); + std::mem::swap(s, &mut *cell.borrow_mut()); + let realm = &mut job.realm; + let r = guard::catch_panic(|| { + #[cfg(test)] + if PANIC_IN_SLICE.with(Cell::get) { + panic!("a slice failed"); + } + realm.advance(SLICE_FUEL) + }); + std::mem::swap(s, &mut *cell.borrow_mut()); + s.script_in_run = false; + job.world.swap(s); + s.depth = depth; + r +} + +/// The objects a run may add before it is asked to continue or stop. +fn growth_objects() -> u64 { + #[cfg(test)] + if let Some(n) = TEST_GROWTH.with(Cell::get) { + return n; + } + GROWTH_OBJECTS +} + +/// The limits of a command's result on its way to the script. +fn result_limits() -> DataLimits { + #[cfg(test)] + if let Some(bytes) = TEST_RESULT_BYTES.with(Cell::get) { + return DataLimits { bytes, ..DataLimits::default() }; + } + DataLimits::default() +} + +#[cfg(test)] +thread_local! { + /// Tests: the bytes a command's result may hold on its way to the script. + pub(crate) static TEST_RESULT_BYTES: Cell> = const { Cell::new(None) }; + /// Tests: makes the next slice panic. + pub(crate) static PANIC_IN_SLICE: Cell = const { Cell::new(false) }; + /// Tests: the objects a run may add in place of [`GROWTH_OBJECTS`]. + pub(crate) static TEST_GROWTH: Cell> = const { Cell::new(None) }; +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 208088a4c..8a1ef8f69 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -129,6 +129,13 @@ impl Session { link_scan: None, global_edit_options: Default::default(), keyboard_text: false, + script_key: None, + script_stop: vectorcraft_js::Interrupt::new(), + script_report: None, + script_frames: false, + script_high_water: Default::default(), + script_in_run: false, + script_copy_first: cfg!(target_arch = "wasm32"), } } diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 7c72da22d..1020e11a5 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -7,6 +7,9 @@ impl Session { /// Run `f` on a mutable copy of the active document. Outside an interaction this records one /// undo step labelled `label`; inside one it just mutates (the interaction commits later). pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result) -> Result { + if self.script_running() { + return Err(EngineError::Busy); + } // An edit made while typing (the Transform panel) is a step of its own: inside the typing // session, the next keystroke's preview would start again from before it (#1881). if !self.previewing && cmd::typing_in_progress(self) { @@ -225,6 +228,9 @@ impl Session { } pub fn begin_interaction(&mut self, label: &str) -> Result<()> { + if self.script_running() { + return Err(EngineError::Busy); + } // Typing in progress is kept first: a drag or a dialog's preview would otherwise start // from the snapshot taken before the typing, and drop it (#1881). if label != "Typing" && cmd::typing_in_progress(self) { @@ -262,6 +268,9 @@ impl Session { /// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview). pub fn preview(&mut self, cmd: &str, params: &Value) -> Result { + if self.script_running() { + return Err(EngineError::Busy); + } let (paint, restore_paint) = { let menu = self.menu.clone(); let st = self.doc_mut()?; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 1a60c2101..c540ae043 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -39,6 +39,14 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + // A clipboard import is a query that replaces the clipboard, which the end of a run sets too. + if self.depth == 0 && self.script_key.is_some() && (waits_for_tool_edit(id) || id.starts_with("clipboard.import")) { + return Err(EngineError::Busy); + } + // A step of a batch a script runs is held to the same rule as the script's own commands. + if self.script_in_run && crate::script::refused_to_scripts(id) { + return Err(EngineError::Other(crate::script::refused_text(id))); + } // An edit the active tool is computing on another thread (a Shape Builder merge) is // applied before a command that may edit, select, save or give a document a new revision, // as the click came first. Queries that only read do not wait for it. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 000741d5d..9dca9b363 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -149,6 +149,7 @@ mod repeat_edit; mod save; mod saveoptions; mod scalestrokes; +mod script_run; mod scripts; mod select_ids_first; mod shaper; diff --git a/crates/engine/src/tests/linkwatch.rs b/crates/engine/src/tests/linkwatch.rs index 37aac5df5..7fc8f0a4b 100644 --- a/crates/engine/src/tests/linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -147,6 +147,26 @@ fn a_look_that_stores_its_stamps_between_the_polls_reads_keeps_them() { assert!(s.link_scan.is_none()); } +#[test] +fn a_change_during_a_script_waits_for_the_script_to_end() { + let dir = Folder::new("watch-script"); + let path = dir.file("art.png"); + std::fs::write(&path, png(40, 40, RED)).unwrap(); + let mut s = session(); + s.prefs.update_links = "automatically".into(); + let id = place(&mut s, &path); + look(&mut s); + save(&path, &png(40, 40, BLUE)); + s.start_link_scan(); + s.wait_link_scan(); + s.script_frames = true; + s.execute("script.run", &json!({"source": "while (true) {}"})).unwrap(); + assert!(s.poll_link_scan().is_none(), "the finished look waits"); + assert!(s.script_abandon()); + assert_eq!(s.poll_link_scan().expect("the stamps are applied").unwrap()["updated"], json!([id.0])); + assert_eq!(colour(&s), BLUE); +} + #[test] fn a_change_during_a_drag_waits_for_the_drag_to_end() { let dir = Folder::new("watch-drag"); diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs new file mode 100644 index 000000000..7912c7b1d --- /dev/null +++ b/crates/engine/src/tests/script_run.rs @@ -0,0 +1,728 @@ +//! `script.run` and `script.stop`: one transaction per run, the rollback after a throw, a limit, a +//! stop or a panic, the mutation lease between slices, ids, growth and what a run reports. + +use std::cell::Cell; + +use serde_json::{Value, json}; + +use crate::script::{PANIC_IN_SLICE, TEST_GROWTH, TEST_RESULT_BYTES}; +use crate::*; + +fn rect(x: i64) -> Value { + json!({"x": x, "y": 0, "width": 10, "height": 10}) +} + +/// Two open documents, each with one rectangle, the second one active. +fn two_docs() -> Session { + let mut s = Session::new(); + for x in [0, 50] { + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &rect(x)).unwrap(); + } + s +} + +/// What a rollback has to put back. +fn state(s: &Session) -> Value { + let docs: Vec = s + .documents() + .iter() + .map(|d| { + json!({ + "uid": d.uid, + "art": format!("{:?}", d.doc.layers), + "selection": d.selection.objects.iter().map(|i| i.0).collect::>(), + "undo": d.history.undo.iter().map(|e| e.label.clone()).collect::>(), + "redo": d.history.redo.len(), + "path": d.path, + "dirty": d.is_dirty(), + }) + }) + .collect(); + json!({"docs": docs, "active": s.active().map(|d| d.uid), "journal": s.journal.len(), "paint": format!("{:?}", s.paint)}) +} + +fn run(s: &mut Session, source: &str) -> Result { + s.execute("script.run", &json!({"source": source, "name": "t.js"})) +} + +fn objects(s: &Session, doc: usize) -> usize { + s.documents()[doc].doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +/// A framed run of `source`, one slice in. +fn paused(s: &mut Session, source: &str) { + s.script_frames = true; + assert_eq!(run(s, source).unwrap()["running"], true); + assert!(s.script_pump().is_none()); + assert!(s.script_running()); +} + +fn two_rect_script() -> String { + "vc.run('shape.rectangle', {x:100,y:0,width:5,height:5});\ + vc.run('shape.rectangle', {x:110,y:0,width:5,height:5});\ + vc.run('document.activate', {index:0});\ + vc.run('shape.rectangle', {x:120,y:0,width:5,height:5});\ + vc.run('document.activate', {index:1});" + .to_string() +} + +#[test] +fn a_run_is_one_undo_step_in_each_document_it_changed() { + let mut s = two_docs(); + let before = state(&s); + let r = run(&mut s, &two_rect_script()).unwrap(); + assert_eq!(r["ok"], true); + assert_eq!((objects(&s, 0), objects(&s, 1)), (2, 3)); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script"); + assert_eq!(s.documents()[1].history.undo.last().unwrap().label, "Run Script"); + assert_eq!(s.documents()[1].history.undo.len(), before["docs"][1]["undo"].as_array().unwrap().len() + 1); + let after = format!("{:?}", s.documents()[1].doc.layers); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s, 1), 1); + assert_eq!(format!("{:?}", s.documents()[1].doc.layers), before["docs"][1]["art"].as_str().unwrap()); + assert_eq!(s.documents()[1].selection.objects.len(), before["docs"][1]["selection"].as_array().unwrap().len()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(format!("{:?}", s.documents()[1].doc.layers), after); +} + +#[test] +fn a_run_that_changes_nothing_leaves_no_step_no_mark_and_no_journal_entry() { + for source in ["", "var a = 1 + 2;", "vc.log(vc.document); vc.commands.length;"] { + let mut s = two_docs(); + s.execute("document.save", &json!({"path": std::env::temp_dir().join(format!("vc-noop-{}.svg", std::process::id())).to_string_lossy()})) + .unwrap(); + let before = state(&s); + let revisions = |s: &Session| s.documents().iter().map(|d| d.revision).collect::>(); + let revisions_before = revisions(&s); + let r = run(&mut s, source).unwrap(); + assert_eq!(r["changed"], json!([]), "{source}"); + assert_eq!(state(&s), before, "{source}"); + assert_eq!(revisions(&s), revisions_before, "{source}"); + } +} + +#[test] +fn a_caught_command_failure_leaves_nothing_behind_and_the_script_goes_on() { + let mut s = two_docs(); + let source = "var m = '';\ + try { vc.run('command.batch', {commands: [\ + {command:'shape.rectangle', params:{x:1,y:1,width:1,height:1}},\ + {command:'no.such', params:{}}]}); } catch (e) { m = e.message; }\ + try { vc.run('shape.rectangle', {x:'bad'}); } catch (e) { m += '|' + e.name; }\ + vc.log(m); vc.run('shape.rectangle', {x:200,y:0,width:5,height:5});"; + let r = run(&mut s, source).unwrap(); + assert_eq!(objects(&s, 1), 2, "{r}"); + assert!(r["log"][0].as_str().unwrap().contains("|Error"), "{r}"); +} + +#[test] +fn a_failure_puts_back_every_document_and_the_session() { + enum How { + Throw, + Fuel, + Stop, + Panic, + } + for how in [How::Throw, How::Fuel, How::Stop, How::Panic] { + let mut s = two_docs(); + let before = state(&s); + let edits = two_rect_script(); + let r = match how { + How::Throw => run(&mut s, &format!("{edits} throw new Error('boom');")), + How::Fuel => s.execute("script.run", &json!({"source": format!("{edits} while (true) {{}}"), "maxFuel": 2_000_000})), + How::Stop => { + paused(&mut s, &format!("{edits} while (true) {{}}")); + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + s.script_pump().unwrap() + } + How::Panic => { + paused(&mut s, &format!("{edits} while (true) {{}}")); + PANIC_IN_SLICE.with(|p| p.set(true)); + let r = s.script_pump().unwrap(); + PANIC_IN_SLICE.with(|p| p.set(false)); + r + } + }; + assert!(r.is_err()); + assert_eq!(state(&s), before); + assert!(!s.script_running()); + s.script_frames = false; + assert_eq!(run(&mut s, "1").unwrap()["ok"], true, "a later run starts"); + } +} + +#[test] +fn a_stop_with_no_run_reports_it() { + assert_eq!(Session::new().execute("script.stop", &json!({})).unwrap()["stopped"], false); +} + +#[test] +fn an_error_names_the_script_its_place_and_kind() { + let mut s = two_docs(); + let e = run(&mut s, "var a;\n throw new TypeError('no');").unwrap_err().to_string(); + assert_eq!(e, "t.js: line 2, column 3: TypeError: no (thrown)"); + let e = run(&mut s, "var = 1;").unwrap_err().to_string(); + assert!(e.starts_with("t.js: line 1,") && e.ends_with("(syntax)"), "{e}"); + assert!(run(&mut s, "app.activeDocument;").unwrap_err().to_string().contains("`vc`")); + assert!(run(&mut s, "#target x\n1").unwrap_err().to_string().contains("#target")); + assert_eq!(s.script_report().unwrap()["line"], 1); +} + +#[test] +fn bad_parameters_are_refused_before_anything_runs() { + let mut s = two_docs(); + let before = state(&s); + let mut deep = json!(1); + for _ in 0..100 { + deep = json!([deep]); + } + for p in [ + json!({}), + json!({"source": "1", "args": {"a": deep}}), + json!({"path": "a", "source": "b"}), + json!({"path": ""}), + json!({"path": 1}), + json!({"source": "1", "nope": 1}), + json!({"source": "1", "args": []}), + json!({"source": "1", "args": {"a": "x".repeat(1 << 20)}}), + json!({"source": "1", "answers": {"a": 1}}), + json!({"source": "1", "answers": 1}), + json!({"source": "1", "maxFuel": 0}), + json!({"source": "1", "maxFuel": -1}), + json!({"source": "1", "maxFuel": 1.5}), + json!({"source": "1", "maxFuel": "9"}), + json!({"source": "1", "maxFuel": 100_000_000_001u64}), + ] { + assert!(matches!(s.execute("script.run", &p), Err(EngineError::BadParams { .. })), "{p}"); + } + assert_eq!(state(&s), before); +} + +#[test] +fn files_are_checked_before_they_are_read() { + let dir = std::env::temp_dir().join(format!("vc-script-run-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let write = |name: &str, bytes: &[u8]| { + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().into_owned() + }; + let mut s = two_docs(); + let path = |p: &str| json!({"path": p}); + let ok = write("Bézier (1) & co.js", b"\xef\xbb\xbfvc.log(1);\r\nvc.log(2);\r\n"); + let r = s.execute("script.run", &path(&ok)).unwrap(); + assert_eq!((r["log"].clone(), r["name"].as_str()), (json!([1, 2]), Some("Bézier (1) & co.js"))); + let invalid = write("a.js", b"var a = '\xff';"); + assert!(s.execute("script.run", &path(&invalid)).unwrap_err().to_string().contains("UTF-8")); + let huge = write("huge.js", &vec![b' '; script::MAX_SOURCE_BYTES + 1]); + assert!(s.execute("script.run", &path(&huge)).unwrap_err().to_string().contains("too large")); + let compiled = write("b.jsxbin", b"@JSXBIN@ES@2.0@"); + assert!(s.execute("script.run", &path(&compiled)).unwrap_err().to_string().contains("compiled")); + let renamed = write("c.js", b"@JSXBIN@ES@2.0@"); + assert!(s.execute("script.run", &path(&renamed)).unwrap_err().to_string().contains("compiled")); + assert!(s.execute("script.run", &path(&dir.to_string_lossy())).is_err()); + let huge_source = " ".repeat(script::MAX_SOURCE_BYTES + 1); + assert!(run(&mut s, &huge_source).unwrap_err().to_string().contains("too large")); + let other = write("notes.txt", b"vc.log(1);"); + assert!(matches!(s.execute("script.run", &path(&other)), Err(EngineError::BadParams { .. })), "a text file that is not a script"); + let upper = write("UP.JSX", b"vc.log(3);"); + assert_eq!(s.execute("script.run", &path(&upper)).unwrap()["log"], json!([3])); + std::fs::create_dir_all(dir.join("folder.js")).unwrap(); + assert!(s.execute("script.run", &path(&dir.join("folder.js").to_string_lossy())).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_failed_batch_puts_back_the_session_and_names_a_file_it_wrote() { + let path = std::env::temp_dir().join(format!("vc-script-batch-{}.svg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().into_owned(); + let mut s = two_docs(); + let source = format!( + "try {{ vc.run('command.batch', {{commands: [\ + {{command: 'view.guides.lock', params: {{locked: true}}}},\ + {{command: 'document.export', params: {{path: {p:?}, format: 'svg'}}}},\ + {{command: 'no.such', params: {{}}}}]}}); }} catch (e) {{ vc.log(e.name); }}" + ); + let r = run(&mut s, &source).unwrap(); + assert_eq!(r["log"], json!(["Error"]), "{r}"); + assert!(!s.guides_locked(), "the failed batch's Lock Guides does not stay"); + assert!(path.exists()); + assert_eq!(r["written"], json!([p]), "{r}"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn an_export_that_fails_names_no_file() { + let path = std::env::temp_dir().join(format!("vc-script-no-folder-{}", std::process::id())).join("out.svg"); + let p = path.to_string_lossy().into_owned(); + let mut s = two_docs(); + let source = format!("try {{ vc.run('document.export', {{path: {p:?}, format: 'svg'}}); }} catch (e) {{ vc.log(e.name); }}"); + let r = run(&mut s, &source).unwrap(); + assert_eq!(r["log"], json!(["Error"]), "{r}"); + assert!(!path.exists()); + assert_eq!(r["written"], json!([]), "{r}"); +} + +#[test] +fn a_failed_run_puts_back_the_choices_its_commands_made() { + let mut s = two_docs(); + let before = s.global_edit_options.clone(); + let source = "vc.run('select.globalEditOptions', {appearance: !vc.run('select.globalEditOptions', {}).appearance}); throw new Error('x');"; + assert!(run(&mut s, source).is_err()); + assert_eq!(s.global_edit_options, before); + let source = "try { vc.run('command.batch', {commands: [\ + {command: 'select.globalEditOptions', params: {size: true}},\ + {command: 'no.such', params: {}}]}); } catch (e) {}"; + run(&mut s, source).unwrap(); + assert_eq!(s.global_edit_options, before, "the failed command's choice went back"); +} + +#[test] +fn a_failed_run_puts_back_the_preferences_its_commands_changed() { + let mut s = two_docs(); + let basic = s.prefs.new_art_basic; + assert!(run(&mut s, &format!("vc.run('appearance.setNewArtBasic', {{on: {}}}); throw new Error('x');", !basic)).is_err()); + assert_eq!(s.prefs.new_art_basic, basic); +} + +#[test] +fn a_finished_run_keeps_its_preferences_and_the_ones_the_app_changed_meanwhile() { + let mut s = two_docs(); + let (basic, increment) = (s.prefs.new_art_basic, s.prefs.keyboard_increment); + paused(&mut s, &format!("vc.run('appearance.setNewArtBasic', {{on: {}}}); for (var i = 0; i < 2000000; i++) {{}}", !basic)); + assert_eq!(s.prefs.new_art_basic, basic, "between slices the app sees the preferences before the run"); + s.prefs.keyboard_increment = increment + 1.0; + while s.script_pump().is_none() {} + assert_eq!((s.prefs.new_art_basic, s.prefs.keyboard_increment), (!basic, increment + 1.0)); +} + +#[test] +fn a_script_cannot_load_the_place_cursor() { + let mut s = two_docs(); + let e = run(&mut s, "vc.run('file.place.queue', {paths: []});").unwrap_err().to_string(); + assert!(e.contains("a script cannot run `file.place.queue`"), "{e}"); + let listed = run(&mut s, "vc.log(vc.commands.some(function (c) { return c.id === 'file.place.queue'; }));").unwrap(); + assert_eq!(listed["log"], json!([false]), "{listed}"); +} + +#[test] +fn a_command_whose_result_cannot_cross_is_put_back() { + TEST_RESULT_BYTES.with(|b| b.set(Some(1))); + let mut s = two_docs(); + let r = run(&mut s, "try { vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); } catch (e) { vc.log(e.name); }"); + TEST_RESULT_BYTES.with(|b| b.set(None)); + let r = r.unwrap(); + assert_eq!(r["log"], json!(["RangeError"]), "{r}"); + assert_eq!(r["changed"], json!([]), "{r}"); + assert_eq!((objects(&s, 0), objects(&s, 1)), (1, 1)); +} + +#[test] +fn an_abandoned_run_reports_the_files_it_wrote() { + let path = std::env::temp_dir().join(format!("vc-script-abandon-{}.svg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().into_owned(); + let mut s = two_docs(); + paused(&mut s, &format!("vc.run('document.export', {{path: {p:?}, format: 'svg'}}); while (true) {{}}")); + assert!(path.exists(), "the first slice exported"); + assert!(s.script_abandon()); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), report["written"].clone()), (Some("stopped"), json!([p])), "{report}"); + assert!(s.script_failure_text().unwrap().contains(&p)); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_run_that_only_closes_a_document_keeps_its_journal_entry() { + let mut s = two_docs(); + let journal = s.journal.len(); + let r = run(&mut s, "vc.run('file.close', {index: 1});").unwrap(); + assert_eq!(r["changed"], json!([]), "{r}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.journal.len(), journal + 1); + assert_eq!(s.journal.iter().last().unwrap().0, "file.close"); +} + +#[test] +fn a_command_that_can_fail_after_copying_has_the_clipboard_put_back() { + let mut s = two_docs(); + let keep_clipboard = |s: &mut Session, id: &str| { + let save = script::Savepoint::take(s, id); + s.clipboard.source_artboard = Some(Default::default()); + save.restore(s); + }; + keep_clipboard(&mut s, "edit.cut"); + assert_eq!(s.clipboard.source_artboard, None, "put back"); + s.clipboard.source_artboard = Some(Default::default()); + keep_clipboard(&mut s, "shape.rectangle"); + assert!(s.clipboard.source_artboard.is_some(), "only those commands pay for a copy of the clipboard"); +} + +#[test] +fn a_snapshot_too_large_to_cross_throws_instead_of_reading_as_no_document() { + let mut s = two_docs(); + s.docs[1].path = Some("x".repeat(17 << 20)); + let r = run(&mut s, "var m = ''; try { vc.document; m = 'read'; } catch (e) { m = e.name + ': ' + e.message; } vc.log(m);").unwrap(); + let m = r["log"][0].as_str().unwrap(); + assert!(m.starts_with("RangeError: vc.document.") && m.ends_with("is too large for a script"), "{m}"); +} + +#[test] +fn recovery_copies_made_between_slices_hold_the_art_before_the_run() { + let store = std::sync::Arc::new(cmd::recovery::MemoryStore::default()); + let mut s = two_docs(); + s.recovery.set_store(store.clone()); + paused(&mut s, &format!("{} while (true) {{}}", two_rect_script())); + cmd::recovery::save_copies(&mut s).unwrap(); + s.script_abandon(); + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store); + let r = s.execute("file.recovery.restore", &json!({})).unwrap(); + assert_eq!(r["restored"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!((objects(&s, 0), objects(&s, 1)), (1, 1), "the copies hold the art from before the run"); +} + +#[test] +fn values_that_cannot_cross_are_named_and_shared_ones_can() { + let mut s = two_docs(); + let cases = [ + ("var a = {}; a.a = a; vc.run('shape.rectangle', a);", "RangeError|TypeError"), + ("var a = {}, c = a; for (var i = 0; i < 100; i++) { c.n = {}; c = c.n; } vc.run('shape.rectangle', a);", "RangeError|TypeError"), + ("vc.run('shape.rectangle', {x: 1/0});", "RangeError|TypeError"), + ("vc.run('shape.rectangle', {x: function () {}});", "RangeError|TypeError"), + ("vc.run('shape.rectangle', 7);", "TypeError"), + ("vc.run(7);", "TypeError"), + ]; + for (source, kinds) in cases { + let e = run(&mut s, source).unwrap_err().to_string(); + assert!(kinds.split('|').any(|k| e.contains(k)), "{source}: {e}"); + } + let r = run(&mut s, "var p = {w: 5}; vc.log({a: p, b: p}); var l = ''; for (var i = 0; i < 20; i++) l += 'xxxxxxxxxx'; vc.log(l);").unwrap(); + assert_eq!(r["log"][0], json!({"a": {"w": 5}, "b": {"w": 5}})); + assert_eq!(r["log"][1].as_str().unwrap().len(), 200); +} + +#[test] +fn commands_outside_the_documents_are_refused_in_a_batch_too() { + let mut s = two_docs(); + for source in [ + "vc.run('prefs.set', {key: 'a', value: 1});", + "vc.run('script.run', {source: '1'});", + "vc.run('script.stop');", + "vc.run('edit.undoGroup.begin', {});", + "vc.run('view.proofColors', {on: true});", + "vc.run('view.proofSetup', {});", + "vc.run('view.overprintPreview', {on: true});", + "vc.run('view.separationsPreview', {});", + "vc.run('command.batch', {commands: [{command: 'prefs.reset', params: {}}]});", + ] { + let e = run(&mut s, source).unwrap_err().to_string(); + assert!(e.contains("cannot run"), "{source}: {e}"); + } + let r = run(&mut s, "var c = vc.commands; var n = 0; for (var i = 0; i < c.length; i++) if (c[i].id.indexOf('prefs.') === 0) n++; vc.log(n); vc.log(c.length > 100);").unwrap(); + assert_eq!(r["log"], json!([0, true])); +} + +#[test] +fn the_vc_object_reads_the_active_document_and_cannot_be_changed() { + let mut s = two_docs(); + let r = s + .execute( + "script.run", + &json!({"name": "x", "args": {"n": 2}, "source": "vc.args.n = 9; vc.api.version = 'x'; vc.log(vc.args.n); vc.log(vc.api.version); vc.log(vc.api.has('run')); vc.log(vc.api.has('nope')); vc.log(vc.document.artboards); vc.log(typeof vc.document.path);"}), + ) + .unwrap(); + assert_eq!(r["log"], json!([2, "1.0", true, false, 1, "object"])); + let mut empty = Session::new(); + assert_eq!(run(&mut empty, "vc.log(vc.document === null);").unwrap()["log"], json!([true])); + assert!(run(&mut empty, "vc.run('shape.rectangle', {x:0,y:0,width:1,height:1});").unwrap_err().to_string().contains("Error")); +} + +#[test] +fn the_log_is_bounded_and_a_script_can_catch_that() { + let mut s = two_docs(); + let r = run( + &mut s, + "var n = 0; try { while (true) { vc.log(n); n++; } } catch (e) { vc.run('document.inspect', {}); throw new Error(e.name + ' at ' + n); }", + ); + let e = r.unwrap_err().to_string(); + assert!(e.contains(&format!("RangeError at {}", script::MAX_LOG_LINES)), "{e}"); +} + +#[test] +fn between_slices_every_change_is_refused_and_readers_see_the_committed_art() { + let mut s = two_docs(); + let before = state(&s); + paused(&mut s, &format!("{} while (true) {{}}", two_rect_script())); + assert_eq!(state(&s), before); + for (id, p) in [ + ("shape.rectangle", rect(1)), + ("clipboard.importSvg", json!({})), + ("clipboard.importImage", json!({})), + ("clipboard.importText", json!({})), + ("clipboard.importPdf", json!({})), + ("clipboard.importEmf", json!({})), + ("clipboard.importEps", json!({})), + ("edit.undo", json!({})), + ("file.new", json!({})), + ("file.close", json!({})), + ("file.revert", json!({})), + ("file.recovery.restore", json!({})), + ("action.batch", json!({})), + ("command.batch", json!({"commands": []})), + ("effect.apply", json!({})), + ("script.run", json!({"source": "1"})), + ] { + assert!(matches!(s.execute(id, &p), Err(EngineError::Busy)), "{id}"); + } + assert!(matches!(s.begin_interaction("x"), Err(EngineError::Busy))); + assert!(matches!(s.edit("x", |_, _| Ok(())), Err(EngineError::Busy))); + assert!(matches!(s.preview("shape.rectangle", &rect(1)), Err(EngineError::Busy))); + let press = tools::PointerEvent { + kind: tools::PointerKind::Down, + ..serde_json::from_value(json!({"kind": "move", "pos": {"x": 0.0, "y": 0.0}})).unwrap() + }; + assert!(matches!(s.pointer(&press, ViewInfo::default()), Err(EngineError::Busy))); + let inspected = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspected["layers"][0]["children"].as_array().map(Vec::len), Some(1), "{inspected}"); + assert_eq!(state(&s), before, "the refused commands changed nothing"); + s.script_abandon(); + assert_eq!(state(&s), before); + assert!(s.execute("shape.rectangle", &rect(1)).unwrap()["id"].is_u64()); +} + +#[test] +fn a_document_closed_or_opened_in_a_run_follows_the_run() { + let closing = "vc.run('file.close', {index: 0}); vc.run('file.new', {}); vc.run('shape.rectangle', {x:1,y:1,width:1,height:1});"; + let mut s = two_docs(); + let before = state(&s); + assert!(run(&mut s, &format!("{closing} throw new Error('x');")).is_err()); + assert_eq!(state(&s), before); + let first = s.documents()[0].uid; + run(&mut s, closing).unwrap(); + assert_eq!(s.documents().len(), 2); + assert!(s.documents().iter().all(|d| d.uid != first)); + assert_eq!(s.documents()[1].history.undo.len(), 0, "a document the script made starts with no undo steps"); +} + +#[test] +fn undo_in_a_run_reaches_only_the_runs_own_steps_and_old_ids_stay_dead() { + let mut s = two_docs(); + let history = s.documents()[1].history.undo.len(); + let source = "var p = vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}).id;\ + vc.run('edit.undo');\ + var q = vc.run('shape.rectangle', {x:2,y:2,width:1,height:1}).id;\ + var why = '';\ + try { vc.run('edit.clear', {ids: [p]}); } catch (e) { why = e.message; }\ + vc.run('edit.undo');\ + try { vc.run('edit.undo'); why += '|undid a step from before the run'; } catch (e) { why += '|stopped'; }\ + vc.log({same: p === q, why: why});\ + vc.run('shape.rectangle', {x:3,y:3,width:1,height:1});"; + let r = run(&mut s, source).unwrap(); + assert_eq!(r["log"][0]["same"], false, "{r}"); + let why = r["log"][0]["why"].as_str().unwrap(); + assert!(why.ends_with("|stopped") && why.len() > "|stopped".len(), "{why}"); + assert_eq!(objects(&s, 1), 2); + assert_eq!(s.documents()[1].history.undo.len(), history + 1); +} + +#[test] +fn a_string_over_the_crossing_limit_does_not_cross() { + let limits = vectorcraft_js::DataLimits { bytes: 20, ..vectorcraft_js::DataLimits::default() }; + assert!(script::to_data(&json!("four"), &limits, 0, &mut (0, 0)).is_ok()); + assert_eq!(script::to_data(&json!("fives"), &limits, 0, &mut (0, 0)), Err(String::new())); +} + +#[test] +fn a_result_too_large_to_cross_names_its_place() { + let limits = vectorcraft_js::DataLimits { bytes: 90, ..vectorcraft_js::DataLimits::default() }; + let v = json!({"a": [1, {"name": "x".repeat(40)}]}); + assert_eq!(script::to_data(&v, &limits, 0, &mut (0, 0)), Err(".a[1].name".to_string())); +} + +#[test] +fn ids_given_out_in_an_abandoned_run_are_not_given_out_again() { + let mut s = two_docs(); + let before = s.active().unwrap().doc.peek_next_id(); + paused(&mut s, &format!("{} while (true) {{}}", two_rect_script())); + assert!(s.script_abandon()); + assert!(s.active().unwrap().doc.peek_next_id() > before); +} + +#[test] +fn ids_given_out_in_a_failed_run_are_not_given_out_again() { + let mut s = two_docs(); + let e = + run(&mut s, "var p = vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}).id; throw new Error('id=' + p + ';');").unwrap_err().to_string(); + let used: u64 = e.split("id=").nth(1).unwrap().split(';').next().unwrap().parse().unwrap(); + let dirty = s.documents()[1].is_dirty(); + let id = s.execute("shape.rectangle", &rect(3)).unwrap()["id"].as_u64().unwrap(); + assert!(id > used, "{id} after {used}"); + assert!(dirty); +} + +#[test] +fn a_rollback_keeps_the_history_after_an_undo_in_the_run() { + let mut s = two_docs(); + let before = state(&s); + let source = "vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); vc.run('edit.undo'); throw new Error('x');"; + assert!(run(&mut s, source).is_err()); + assert_eq!(state(&s), before); +} + +#[test] +fn a_batch_joins_the_runs_one_step() { + let mut s = two_docs(); + let undo = s.documents()[1].history.undo.len(); + let source = "vc.run('command.batch', {commands: [\ + {command:'shape.rectangle', params:{x:1,y:1,width:1,height:1}},\ + {command:'shape.rectangle', params:{x:2,y:2,width:1,height:1}}]});"; + run(&mut s, source).unwrap(); + assert_eq!(s.documents()[1].history.undo.len(), undo + 1); + assert_eq!(objects(&s, 1), 3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s, 1), 1); +} + +#[test] +fn a_run_with_nobody_to_ask_ends_at_its_growth_allowance() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let mut s = two_docs(); + let before = state(&s); + let source = "try { while (true) vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); } catch (e) { vc.run('shape.rectangle', {x:2,y:1,width:1,height:1}); }"; + let e = run(&mut s, source).unwrap_err().to_string(); + TEST_GROWTH.with(|g| g.set(None)); + assert!(e.contains("objects and data"), "{e}"); + assert_eq!(state(&s), before); +} + +#[test] +fn a_person_can_let_a_growing_run_go_on_or_stop_it() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let run_to_the_question = |s: &mut Session| { + s.script_frames = true; + run(s, "for (var i = 0; i < 25; i++) vc.run('shape.rectangle', {x:i,y:1,width:1,height:1});").unwrap(); + let slices = Cell::new(0); + while !s.script_waiting() { + assert!(s.script_pump().is_none()); + slices.set(slices.get() + 1); + assert!(slices.get() < 1_000); + } + // Waiting takes no fuel, however often the frame comes round. + assert!(s.script_pump().is_none()); + }; + let mut s = two_docs(); + run_to_the_question(&mut s); + s.script_continue(); + assert!(!s.script_waiting()); + let done = loop { + if let Some(r) = s.script_pump() { + break r; + } + }; + assert_eq!(done.unwrap()["ok"], true); + assert_eq!(objects(&s, 1), 26); + + let mut s = two_docs(); + let before = state(&s); + run_to_the_question(&mut s); + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert!(!s.script_waiting()); + assert!(s.script_pump().unwrap().is_err()); + TEST_GROWTH.with(|g| g.set(None)); + assert_eq!(state(&s), before); +} + +#[test] +fn a_file_saved_before_a_failure_stays_written_and_is_named() { + let path = std::env::temp_dir().join(format!("vc-script-save-{}.svg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().into_owned(); + let mut s = two_docs(); + let before = state(&s); + let source = + format!("vc.run('file.saveAs', {{path: {p:?}}}); vc.run('shape.rectangle', {{x:1,y:1,width:1,height:1}}); throw new Error('later');"); + let e = run(&mut s, &source).unwrap_err().to_string(); + assert!(path.exists()); + assert!(e.contains(&p), "{e}"); + assert_eq!(s.script_report().unwrap()["written"], json!([p])); + assert_eq!(state(&s), before, "the document does not keep the new path"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_committed_run_exports_what_it_drew() { + let path = std::env::temp_dir().join(format!("vc-script-export-{}.svg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().into_owned(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = "for (var i = 0; i < 7; i++) vc.run('shape.rectangle', {x:i*20,y:5,width:10,height:10});"; + run(&mut s, source).unwrap(); + s.execute("document.save", &json!({"path": p})).unwrap(); + let svg = std::fs::read_to_string(&path).unwrap(); + assert_eq!(svg.matches(" Result> { + if self.script_running() { + return if ev.kind == PointerKind::Move { Ok(vec![]) } else { Err(EngineError::Busy) }; + } let n = self.next_input(); if ev.kind != PointerKind::Move && (!self.held_input.is_empty() || self.tool_edit_pending()) { self.held_input.push_back((n, HeldInput::Pointer(*ev), view)); @@ -877,6 +880,9 @@ impl Session { /// A key for the active tool. While the tool computes an edit on another thread, the key is held /// and made once the edit is applied, after the input held before it. pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result> { + if self.script_running() { + return Err(EngineError::Busy); + } let n = self.next_input(); if self.tool_editing() { self.held_input.push_back((n, HeldInput::Key(key, mods), view)); @@ -910,6 +916,9 @@ impl Session { /// Typed text for the active tool (Type tool). pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result> { + if self.script_running() { + return Err(EngineError::Busy); + } let acts = self.with_tool_cx(view, |t, cx| t.text_input(cx, text)); self.take_tool_panic()?; self.apply_actions(acts) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 984d3a123..d420b94b3 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -501,7 +501,7 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> return Ok(Value::String(Str::from_units(units.to_vec()))); } Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), - Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Host { name, .. } | Func::Data { name, .. }) => format!("function {name}() {{ [native code] }}"), Some(Func::Bound { .. }) => "function () { [native code] }".to_string(), None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), }; diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 1b2099fe1..a53721fc7 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -158,6 +158,9 @@ pub(crate) type NativeFn = fn(&mut crate::interp::Interp, Value, &[Value], bool) /// A function the embedding defines. pub(crate) type HostFn = dyn Fn(&[crate::JsValue]) -> Result; +/// A function the embedding defines that takes and gives structured values. +pub(crate) type DataFn = crate::host::HostFunction; + /// The built-ins the interpreter treats specially when it calls them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum NativeKind { @@ -177,6 +180,7 @@ pub(crate) enum Func { Closure { code: Rc, env: EnvId }, Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, + Data { f: DataFn, name: Rc }, Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs new file mode 100644 index 000000000..457b2ce2e --- /dev/null +++ b/crates/js/src/host.rs @@ -0,0 +1,351 @@ +//! Structured values between a script and its host: [`Data`] (JSON's shapes and `undefined`), the +//! checks a value passes on its way across, and the frozen objects a host adds to the global +//! environment. +//! +//! A value read from a script may hold plain objects, arrays, strings, finite numbers, booleans, +//! `null` and `undefined`. A cycle, a value too deep or too large, a function, a getter, a date, a +//! regular expression, a boxed primitive, a hole in an array and a number that is not finite are +//! refused with a `TypeError` or `RangeError` naming where the value is. The same object may appear +//! more than once when it is not its own ancestor; each appearance counts against the size limit, +//! so sharing cannot multiply the work without limit. + +use std::rc::Rc; + +use crate::LimitKind; +use crate::heap::{Class, ENUMERABLE, Func, Key, OBJECT_BYTES, ObjId, Object, PROP_BYTES, Prop, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::value::{Value, index_key}; + +/// A value that crosses between a script and the host. +#[derive(Clone, Debug, PartialEq)] +pub enum Data { + Undefined, + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + /// An object's own enumerable properties, in the order the object lists them. + Object(Vec<(String, Data)>), +} + +/// How much one crossing may carry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DataLimits { + /// How deeply arrays and objects may nest. + pub depth: usize, + /// Estimated bytes of the strings, names and numbers. + pub bytes: usize, + /// Array elements and object properties, all of them counted together. + pub entries: usize, +} + +impl Default for DataLimits { + fn default() -> Self { + Self { depth: 64, bytes: 16 << 20, entries: 1 << 20 } + } +} + +/// How a host function fails. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostError { + /// Thrown to the script as an `Error`; the script can catch it. + Error(String), + /// Thrown to the script as a `TypeError`. + Type(String), + /// Thrown to the script as a `RangeError`. + Range(String), + /// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it. + Abort(String), +} + +/// A function the host gives a script, with its arguments converted. +pub type HostFunction = Rc) -> Result>; + +/// A member of an object the host adds to the global environment. +#[derive(Clone)] +pub enum HostItem { + /// A read-only copy of a value. + Value(Data), + Function(HostFunction), + /// A property whose value the function gives each time it is read. + Getter(HostFunction), + /// A frozen object with these members. + Object(Vec<(String, HostItem)>), +} + +enum Step { + Name(Key), + Index(u32), +} + +/// A value being read: what it has used so far, and where the reader is. +struct Reading { + limits: DataLimits, + bytes: usize, + entries: usize, + ancestors: Vec, + path: Vec, + function: Rc, +} + +impl Reading { + fn place(&self) -> String { + let mut s = String::new(); + for step in &self.path { + match step { + Step::Index(i) => s.push_str(&format!("[{i}]")), + Step::Name(k) => { + s.push('.'); + s.push_str(&String::from_utf16_lossy(k)); + } + } + } + s + } +} + +enum Shape { + Array(u32), + Plain(Vec<(Key, Prop)>), +} + +impl Interp { + fn data_error(&mut self, r: &Reading, t: ErrorType, what: &str) -> Abrupt { + let message = format!("{}: argument{} {what}", r.function, r.place()); + self.throw(t, &message) + } + + fn charge_reading(&mut self, r: &mut Reading, bytes: usize) -> JsResult<()> { + r.bytes = r.bytes.saturating_add(bytes); + if r.bytes > r.limits.bytes { + return Err(self.data_error(r, ErrorType::Range, "is too large")); + } + Ok(()) + } + + fn charge_entries(&mut self, r: &mut Reading, n: usize) -> JsResult<()> { + r.entries = r.entries.saturating_add(n); + if r.entries > r.limits.entries { + return Err(self.data_error(r, ErrorType::Range, "has too many entries")); + } + Ok(()) + } + + fn read_data(&mut self, v: &Value, r: &mut Reading) -> JsResult { + match v { + Value::Undefined => Ok(Data::Undefined), + Value::Null => Ok(Data::Null), + Value::Bool(b) => Ok(Data::Bool(*b)), + Value::Number(n) if n.is_finite() => Ok(Data::Number(*n)), + Value::Number(_) => Err(self.data_error(r, ErrorType::Type, "is not a finite number")), + Value::String(s) => { + self.charge_reading(r, s.len().saturating_mul(3).saturating_add(2))?; + Ok(Data::String(s.to_rust_string())) + } + Value::Object(id) => self.read_object(*id, r), + } + } + + fn read_object(&mut self, id: ObjId, r: &mut Reading) -> JsResult { + self.use_fuel()?; + if r.ancestors.contains(&id) { + return Err(self.data_error(r, ErrorType::Type, "refers to itself")); + } + if r.ancestors.len() >= r.limits.depth { + return Err(self.data_error(r, ErrorType::Range, "is nested too deep")); + } + let shape = match &self.object(id)?.class { + Class::Array { length, .. } => Shape::Array(*length), + Class::Ordinary => { + let o = self.object(id)?; + if o.props.len() > r.limits.entries { + return Err(self.data_error(r, ErrorType::Range, "has too many entries")); + } + Shape::Plain(o.props.iter().filter(|(_, p)| p.enumerable()).map(|(k, p)| (k.clone(), p.clone())).collect()) + } + other => { + let what = format!("is a {}, not plain data", other.name()); + return Err(self.data_error(r, ErrorType::Type, &what)); + } + }; + r.ancestors.push(id); + let out = match shape { + Shape::Array(length) => self.read_array(id, length, r), + Shape::Plain(props) => self.read_plain(props, r), + }; + r.ancestors.pop(); + out + } + + fn read_array(&mut self, id: ObjId, length: u32, r: &mut Reading) -> JsResult { + self.charge_entries(r, length as usize)?; + let mut items = Vec::with_capacity(length as usize); + for i in 0..length { + self.use_fuel()?; + r.path.push(Step::Index(i)); + let prop = self.object(id)?.props.get(&index_key(i)).cloned(); + let item = match prop { + Some(Prop::Data { value: Value::Undefined, .. }) | None => Err(self.data_error(r, ErrorType::Type, "is undefined")), + Some(Prop::Data { value, .. }) => self.read_data(&value, r), + Some(Prop::Accessor { .. }) => Err(self.data_error(r, ErrorType::Type, "is a getter, not plain data")), + }?; + r.path.pop(); + items.push(item); + } + Ok(Data::Array(items)) + } + + fn read_plain(&mut self, props: Vec<(Key, Prop)>, r: &mut Reading) -> JsResult { + self.charge_entries(r, props.len())?; + let mut out = Vec::with_capacity(props.len()); + for (k, prop) in props { + self.use_fuel()?; + self.charge_reading(r, k.len().saturating_mul(3).saturating_add(8))?; + let name = String::from_utf16_lossy(&k); + r.path.push(Step::Name(k)); + let item = match prop { + // A property holding `undefined` reads as one left out. + Prop::Data { value: Value::Undefined, .. } => None, + Prop::Data { value, .. } => Some(self.read_data(&value, r)?), + Prop::Accessor { .. } => return Err(self.data_error(r, ErrorType::Type, "is a getter, not plain data")), + }; + r.path.pop(); + if let Some(item) = item { + out.push((name, item)); + } + } + Ok(Data::Object(out)) + } + + /// A host value as a JavaScript value, `frozen` when a script may not change it. A value + /// deeper or longer than the limits throws a `RangeError`. + pub(crate) fn write_data(&mut self, d: Data, depth: usize, frozen: bool) -> JsResult { + if depth > self.data_limits.depth { + return Err(self.throw(ErrorType::Range, "a host value is nested too deep")); + } + Ok(match d { + Data::Undefined => Value::Undefined, + Data::Null => Value::Null, + Data::Bool(b) => Value::Bool(b), + Data::Number(n) => Value::Number(n), + Data::String(s) => { + let bytes = string_bytes(s.encode_utf16().count()); + self.reserve_heap(bytes)?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + Value::str(&s) + } + Data::Array(items) => { + self.write_container(items.into_iter().enumerate().map(|(i, d)| (Slot::Index(i), d)).collect(), true, depth, frozen)? + } + Data::Object(items) => self.write_container(items.into_iter().map(|(k, d)| (Slot::Name(k), d)).collect(), false, depth, frozen)?, + }) + } + + fn write_container(&mut self, items: Vec<(Slot, Data)>, array: bool, depth: usize, frozen: bool) -> JsResult { + if items.len() > self.data_limits.entries { + return Err(self.throw(ErrorType::Range, "a host value has too many entries")); + } + let flags = if frozen { ENUMERABLE } else { crate::heap::OPEN }; + self.reserve_heap(OBJECT_BYTES.saturating_add(PROP_BYTES.saturating_mul(items.len())))?; + let id = if array { + let proto = self.intrinsics.array_proto; + self.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: !frozen }, Some(proto))) + } else { + self.new_object() + }; + let mark = self.temp_roots.len(); + self.temp_roots.push(Value::Object(id)); + let mut result = Ok(()); + for (slot, d) in items { + let name = match slot { + Slot::Index(i) => index_key(u32::try_from(i).unwrap_or(u32::MAX)), + Slot::Name(n) => key(&n), + }; + result = self.write_data(d, depth + 1, frozen).and_then(|value| self.define(id, name, Prop::Data { value, flags })); + if result.is_err() { + break; + } + } + self.temp_roots.truncate(mark); + result?; + if frozen { + self.object_mut(id)?.extensible = false; + } + Ok(Value::Object(id)) + } + + /// Calls host function `f`: reads its arguments, runs it, and gives its result to the script. + pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value]) -> JsResult { + let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() }; + let mut data = Vec::with_capacity(args.len()); + for (i, a) in args.iter().enumerate() { + reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); + data.push(self.read_data(a, &mut reading)?); + reading.path.pop(); + } + match f(data) { + Ok(d) => self.write_data(d, 0, false), + Err(HostError::Abort(message)) => { + self.host_abort = Some(message); + Err(Abrupt::Limit(LimitKind::Host)) + } + Err(HostError::Error(message)) => Err(self.throw_host(ErrorType::Error, &message)?), + Err(HostError::Type(message)) => Err(self.throw_host(ErrorType::Type, &message)?), + Err(HostError::Range(message)) => Err(self.throw_host(ErrorType::Range, &message)?), + } + } + + /// The exception for a host error, charged to the heap. + fn throw_host(&mut self, t: ErrorType, message: &str) -> JsResult { + let bytes = string_bytes(message.encode_utf16().count()); + self.reserve_heap(bytes)?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + Ok(self.throw(t, message)) + } + + /// Adds the frozen object `name` with `items` to the global environment. + pub(crate) fn install_host_object(&mut self, name: &str, items: Vec<(String, HostItem)>) -> JsResult<()> { + let o = self.host_object(name, items)?; + let global = self.global; + self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: 0 }) + } + + fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { + let o = self.new_object(); + let mark = self.temp_roots.len(); + self.temp_roots.push(Value::Object(o)); + let mut result = Ok(()); + for (name, item) in items { + result = self.install_item(o, path, name, item); + if result.is_err() { + break; + } + } + self.temp_roots.truncate(mark); + result?; + self.object_mut(o)?.extensible = false; + Ok(o) + } + + fn install_item(&mut self, o: ObjId, path: &str, name: String, item: HostItem) -> JsResult<()> { + let full = format!("{path}.{name}"); + let prop = match item { + HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, + HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full)), flags: ENUMERABLE }, + HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full)), set: None, flags: ENUMERABLE }, + HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, + }; + self.define(o, key(&name), prop) + } + + fn host_function(&mut self, f: HostFunction, name: &str) -> ObjId { + let proto = self.intrinsics.function_proto; + self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into() }), Some(proto))) + } +} + +enum Slot { + Index(usize), + Name(String), +} diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs new file mode 100644 index 000000000..d43e26f5b --- /dev/null +++ b/crates/js/src/host_tests.rs @@ -0,0 +1,277 @@ +//! Resumable runs and the structured values host functions take and give. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use super::*; + +fn realm(fuel: u64) -> Realm { + let mut r = Realm::new(); + r.set_limits(Limits { fuel, ..Limits::default() }); + r +} + +/// A realm with `host.take(value)`, which records what it was given and returns it. +fn with_take(r: &mut Realm) -> Rc>> { + let seen: Rc>> = Rc::default(); + let log = seen.clone(); + let f: HostFunction = Rc::new(move |mut args| { + let v = if args.is_empty() { Data::Undefined } else { args.remove(0) }; + log.borrow_mut().push(v.clone()); + Ok(v) + }); + r.define_host_object("host", vec![("take".into(), HostItem::Function(f))]).unwrap(); + seen +} + +fn finish(r: &mut Realm, slice: u64) -> (Result, u32) { + let mut slices = 0; + loop { + slices += 1; + match r.advance(slice) { + Ok(Progress::Paused) => {} + Ok(Progress::Done(v)) => return (Ok(v), slices), + Err(e) => return (Err(e), slices), + } + } +} + +#[test] +fn a_resumed_run_gives_the_result_of_one_run() { + let source = "var t = 0; for (var i = 0; i < 2000; i++) { t += i; } t"; + let whole = realm(10_000_000).run(source).unwrap(); + let mut r = realm(10_000_000); + r.start(source).unwrap(); + assert!(r.is_running()); + let (v, slices) = finish(&mut r, 100); + assert_eq!(v.unwrap(), whole); + assert!(slices > 10, "the run paused: {slices} slices"); + assert!(!r.is_running()); +} + +#[test] +fn state_survives_a_pause_and_a_collection_between_slices() { + let mut r = realm(10_000_000); + r.start("var o = {a: [1, 2, 3]}; var n = 0; for (var i = 0; i < 500; i++) { n += o.a.length; } n").unwrap(); + assert_eq!(r.advance(50).unwrap(), Progress::Paused); + r.collect_garbage(); + assert_eq!(finish(&mut r, 50).0.unwrap(), JsValue::Number(1500.0)); +} + +#[test] +fn the_fuel_limit_covers_every_slice_together() { + let mut r = realm(5_000); + r.start("while (true) {}").unwrap(); + let (v, slices) = finish(&mut r, 100); + assert!(matches!(v, Err(RunError { kind: RunErrorKind::Limit(LimitKind::Fuel), .. }))); + assert!((40..=60).contains(&slices), "{slices}"); +} + +#[test] +fn a_set_flag_stops_a_loop_in_a_getter() { + let mut r = realm(u64::MAX); + r.start("var o = {}; Object.defineProperty(o, 'x', {get: function () { while (true) {} }}); o.x").unwrap(); + r.interrupt().set(); + let e = r.advance(10).unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Interrupted)); +} + +#[test] +fn a_nested_run_past_the_slice_allowance_is_the_unbroken_limit_and_a_run_with_little_fuel_is_the_fuel_limit() { + for (total, kind) in [(u64::MAX, LimitKind::Unbroken), (1_000, LimitKind::Fuel)] { + for source in [ + "var o = {}; Object.defineProperty(o, 'x', {get: function () { while (true) {} }}); o.x", + "[1].forEach(function () { while (true) {} });", + ] { + let mut r = realm(total); + r.set_unbroken_fuel(5_000); + r.start(source).unwrap(); + let e = finish(&mut r, 1).0.unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(kind), "{source}"); + } + } +} + +#[test] +fn neither_a_limit_nor_a_stop_can_be_caught() { + let mut r = realm(1_000); + r.start("try { while (true) {} } catch (e) { 'caught' } finally { 'ran' }").unwrap(); + assert_eq!(finish(&mut r, 50).0.unwrap_err().kind, RunErrorKind::Limit(LimitKind::Fuel)); + + let shared = Interrupt::new(); + let mut r = realm(1_000_000); + r.share_interrupt(&shared); + r.start("try { while (true) {} } catch (e) { 'caught' }").unwrap(); + assert_eq!(r.advance(10).unwrap(), Progress::Paused); + shared.set(); + assert_eq!(r.advance(10).unwrap_err().kind, RunErrorKind::Limit(LimitKind::Interrupted)); +} + +#[test] +fn a_host_function_can_end_the_slice() { + let mut r = realm(1_000_000); + let pause = r.pause_flag(); + let f: HostFunction = Rc::new(move |_| { + pause.set(true); + Ok(Data::Null) + }); + r.define_host_object("host", vec![("yield".into(), HostItem::Function(f))]).unwrap(); + r.start("host.yield(); host.yield(); 7").unwrap(); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert_eq!(r.advance(1_000).unwrap(), Progress::Done(JsValue::Number(7.0))); +} + +#[test] +fn plain_values_cross_in_both_directions() { + let mut r = realm(1_000_000); + let seen = with_take(&mut r); + let out = r.run("var v = host.take({a: 1, b: 'x', c: [true, null], d: undefined}); v.c[0] === true && v.b === 'x' && v.d === undefined"); + assert_eq!(out.unwrap(), JsValue::Bool(true)); + let want = Data::Object(vec![ + ("a".into(), Data::Number(1.0)), + ("b".into(), Data::String("x".into())), + ("c".into(), Data::Array(vec![Data::Bool(true), Data::Null])), + ]); + assert_eq!(seen.borrow().as_slice(), [want]); +} + +#[test] +fn a_callback_of_a_built_in_can_call_the_host() { + let mut r = realm(1_000_000); + let seen = with_take(&mut r); + r.start("[1, 2, 3].forEach(function (n) { host.take(n); });").unwrap(); + finish(&mut r, 2).0.unwrap(); + assert_eq!(seen.borrow().len(), 3); +} + +/// `value` passed to the host throws an error of type `name` whose message holds `part`. +fn refused(setup: &str, name: &str, part: &str) { + let mut r = realm(10_000_000); + r.set_data_limits(DataLimits { depth: 8, bytes: 1_000, entries: 100 }); + let seen = with_take(&mut r); + let probe = format!("var e; {setup} try {{ host.take(value); }} catch (x) {{ e = x; }} e ? e.name + '|' + e.message : 'none'"); + let JsValue::String(out) = r.run(&probe).unwrap() else { panic!("{setup}") }; + assert!(out.starts_with(name) && out.contains(part), "{setup}: {out}"); + assert!(seen.borrow().is_empty(), "{setup}: the function ran"); +} + +#[test] +fn values_that_are_not_plain_data_are_refused_with_their_place() { + refused("var value = {}; value.self = value;", "TypeError", ".self refers to itself"); + refused("var value = [[1]]; value[0][0] = value;", "TypeError", "[0][0][0] refers to itself"); + refused("var value = {f: function () {}};", "TypeError", ".f is a Function"); + refused("var value = {n: 1 / 0};", "TypeError", ".n is not a finite number"); + refused("var value = {n: 0 / 0};", "TypeError", ".n is not a finite number"); + refused("var value = {d: new Date(0)};", "TypeError", ".d is a Date"); + refused("var value = {}; Object.defineProperty(value, 'g', {enumerable: true, get: function () { return 1; }});", "TypeError", ".g is a getter"); + refused("var value = [1, , 3];", "TypeError", "[0][1] is undefined"); + refused("var value = 1; for (var i = 0; i < 20; i++) value = [value];", "RangeError", "nested too deep"); + refused("var value = new Array(5000);", "RangeError", "too many entries"); + refused("var value = 'x'; for (var i = 0; i < 12; i++) value += value;", "RangeError", "too large"); +} + +#[test] +fn a_shared_object_is_allowed_and_sharing_cannot_multiply_the_work() { + let mut r = realm(10_000_000); + r.set_data_limits(DataLimits { depth: 64, bytes: 16 << 20, entries: 1_000 }); + let seen = with_take(&mut r); + r.run("var s = {a: 1}; host.take({x: s, y: s, z: [s, s]});").unwrap(); + assert_eq!(seen.borrow().len(), 1); + // Each level doubles the paths to the bottom: the entry limit ends the walk. + let JsValue::String(out) = + r.run("var v = [1]; for (var i = 0; i < 40; i++) v = [v, v]; var m = 'none'; try { host.take(v); } catch (e) { m = e.name; } m").unwrap() + else { + panic!() + }; + assert_eq!(out, "RangeError"); +} + +#[test] +fn a_host_object_cannot_be_changed_by_a_script() { + let mut r = realm(1_000_000); + let args = Data::Object(vec![("list".into(), Data::Array(vec![Data::Number(1.0)]))]); + r.define_host_object("vc", vec![("args".into(), HostItem::Value(args))]).unwrap(); + let out = r.run( + "vc.args.list[0] = 2; vc.args.list.push; vc.args.more = 1; vc.extra = 1; var was = vc; vc = 3; \ + [was.args.list[0], was.args.more === undefined, was.extra === undefined, Object.isFrozen(was.args.list)].join()", + ); + assert_eq!(out.unwrap(), JsValue::String("1,true,true,true".into())); +} + +#[test] +fn a_getter_is_read_each_time() { + let mut r = realm(1_000_000); + let count = Rc::new(Cell::new(0.0)); + let c = count.clone(); + let f: HostFunction = Rc::new(move |_| { + c.set(c.get() + 1.0); + Ok(Data::Number(c.get())) + }); + r.define_host_object("host", vec![("n".into(), HostItem::Getter(f))]).unwrap(); + assert_eq!(r.run("host.n + host.n").unwrap(), JsValue::Number(3.0)); +} + +#[test] +fn errors_from_the_host_can_be_caught_and_an_abort_cannot() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|a| match a.first() { + Some(Data::String(k)) if k == "type" => Err(HostError::Type("bad type".into())), + Some(Data::String(k)) if k == "range" => Err(HostError::Range("bad range".into())), + Some(Data::String(k)) if k == "abort" => Err(HostError::Abort("stopped by the host".into())), + _ => Err(HostError::Error("bad".into())), + }); + r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap(); + let out = r.run( + "var a = []; ['type', 'range', 'x'].forEach(function (k) { try { host.f(k); } catch (e) { a.push(e.name + ':' + e.message); } }); a.join()", + ); + assert_eq!(out.unwrap(), JsValue::String("TypeError:bad type,RangeError:bad range,Error:bad".into())); + let e = r.run("try { host.f('abort'); } catch (e) { 'caught' }").unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped by the host")); +} + +#[test] +fn an_error_says_where_in_the_source_it_was_raised() { + let mut r = realm(1_000_000); + r.start("var a = 1;\n throw new TypeError('no');").unwrap(); + let e = finish(&mut r, 1).0.unwrap_err(); + assert_eq!((e.kind, e.line), (RunErrorKind::Thrown, 2)); + assert!(e.column >= 3, "{}", e.column); + assert_eq!(e.message, "TypeError: no"); +} + +#[test] +fn advancing_without_a_run_is_an_error() { + let mut r = realm(1_000); + let e = r.advance(10).unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "no run to advance")); +} + +proptest::proptest! { + /// A slice size does not change what a script computes. + #[test] + fn any_slice_size_gives_the_same_result(slice in 1u64..500, n in 0u32..300) { + let source = format!("var a = []; for (var i = 0; i < {n}; i++) a.push(i * 2); a.join().length"); + let whole = realm(50_000_000).run(&source).unwrap(); + let mut r = realm(50_000_000); + r.start(&source).unwrap(); + proptest::prop_assert_eq!(finish(&mut r, slice).0.unwrap(), whole); + } + + /// Hostile text for a value never makes the bridge panic. + #[test] + fn hostile_values_end_with_a_value_or_a_script_error(depth in 0usize..120, width in 0usize..6, cycle in proptest::bool::ANY) { + let mut r = realm(5_000_000); + with_take(&mut r); + let mut s = String::from("var v = [];"); + for _ in 0..depth { + s.push_str(&format!("v = [v{}];", ",v".repeat(width.min(2)))); + } + if cycle { + s.push_str("var c = {}; c.c = c; v = [v, c];"); + } + s.push_str("var ok = 'ok'; try { host.take(v); } catch (e) { ok = e.name; } ok"); + let JsValue::String(out) = r.run(&s).unwrap() else { panic!() }; + proptest::prop_assert!(matches!(out.as_str(), "ok" | "RangeError" | "TypeError"), "{}", out); + } +} diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b110a955b..f86814949 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -179,6 +179,12 @@ pub(crate) struct Interp { pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. pub(crate) held: Rc, + /// Set by the host to end the current slice of a resumable run at the next safe point. + pub(crate) pause: Rc>, + /// The message of the uncatchable error a host function ended the run with. + pub(crate) host_abort: Option, + /// How much a value passed to or from a host function may carry. + pub(crate) data_limits: crate::DataLimits, } /// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's @@ -367,6 +373,9 @@ impl Interp { install_error: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), + pause: Rc::default(), + host_abort: None, + data_limits: crate::DataLimits::default(), }; crate::builtins::install(&mut interp); interp.fuel = limits.fuel; @@ -558,6 +567,28 @@ impl Interp { } } + /// Runs the boundary frame on top until it returns (`Some` with its value) or the remaining + /// fuel falls to `pause_at`, or the host asks for a pause (`None`, with the frames left in + /// place for the next call). Only this outermost loop pauses: a nested run has native frames + /// under it and goes on until it returns. + pub(crate) fn execute_slice(&mut self, pause_at: u64) -> JsResult> { + loop { + match self.step() { + Ok(Some(v)) => return Ok(Some(v)), + Ok(None) => {} + Err(Abrupt::Throw(v)) => self.unwind(v)?, + Err(other) => { + self.note_site(); + self.abort_to_boundary(); + return Err(other); + } + } + if self.fuel <= pause_at || self.pause.replace(false) { + return Ok(None); + } + } + } + fn step(&mut self) -> JsResult> { self.tick()?; let frame = self.frame_mut()?; @@ -966,11 +997,11 @@ impl Interp { // ---- properties ---- - fn object(&self, id: ObjId) -> JsResult<&Object> { + pub(crate) fn object(&self, id: ObjId) -> JsResult<&Object> { self.heap.object(id).ok_or_else(|| Abrupt::Internal("object out of range".into())) } - fn object_mut(&mut self, id: ObjId) -> JsResult<&mut Object> { + pub(crate) fn object_mut(&mut self, id: ObjId) -> JsResult<&mut Object> { self.heap.object_mut(id).ok_or_else(|| Abrupt::Internal("object out of range".into())) } @@ -1815,6 +1846,11 @@ impl Interp { self.temp_roots.truncate(mark); return Ok(Prepared::Done(r?)); } + Func::Data { f, name } => { + // The arguments are read before the function runs, and a result being built + // keeps itself reachable. + return Ok(Prepared::Done(self.call_data(&f, &name, &args)?)); + } Func::Host { f: host, .. } => { let js_args: Vec = args.iter().map(|v| self.to_js_value(v)).collect(); return match host(&js_args) { @@ -2135,6 +2171,15 @@ impl Interp { if is_eval && self.reentry >= self.limits.reentry { return Err(Abrupt::Limit(LimitKind::Reentry)); } + self.enter_global(code, is_eval)?; + self.reentry += nested; + let r = self.execute(); + self.reentry -= nested; + r + } + + /// Declares `code`'s variables and functions and pushes its boundary frame, ready to run. + pub(crate) fn enter_global(&mut self, code: Rc, is_eval: bool) -> JsResult<()> { self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; let (env, global) = (self.global_env, self.global); let (env, var_env) = if code.strict && is_eval { @@ -2144,11 +2189,7 @@ impl Interp { (env, env) }; self.instantiate_global(&code, var_env, is_eval)?; - self.push_code_frame(code, env, var_env, Value::Object(global), true)?; - self.reentry += nested; - let r = self.execute(); - self.reentry -= nested; - r + self.push_code_frame(code, env, var_env, Value::Object(global), true) } fn push_code_frame(&mut self, code: Rc, env: EnvId, var_env: EnvId, this: Value, boundary: bool) -> JsResult<()> { diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index b883be432..a95a4b884 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -47,6 +47,7 @@ mod compiler; mod date_builtins; mod error; mod heap; +mod host; mod interp; mod lexer; mod number; @@ -65,9 +66,10 @@ pub use ast::{ PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, }; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; +pub use host::{Data, DataLimits, HostError, HostFunction, HostItem}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; -pub use realm::{Interrupt, JsValue, LimitKind, Limits, Realm, RunError, RunErrorKind}; +pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind}; pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; /// How a script is read. @@ -185,6 +187,8 @@ impl LineTable { #[cfg(test)] mod builtins_tests; #[cfg(test)] +mod host_tests; +#[cfg(test)] mod interp_tests; #[cfg(test)] mod tests; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 45d7c08bd..a28b150a5 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -51,14 +51,36 @@ pub enum LimitKind { Reentry, /// The host set the interrupt flag. Interrupted, + /// A getter, a callback of a built-in or another nested run spent [`MAX_UNBROKEN_FUEL`] + /// without returning to the point where a resumable run can pause. + Unbroken, + /// A host function ended the run ([`crate::HostError::Abort`]). + Host, +} + +/// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run +/// it cannot pause. +pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000; + +/// How far a resumable run got. +#[derive(Clone, Debug, PartialEq)] +pub enum Progress { + /// The slice ended; call [`Realm::advance`] again. + Paused, + /// The script ended, with its completion value. + Done(JsValue), } /// A flag the host sets, from any thread, to stop the running script at its next instruction. /// It stays set until [`Interrupt::clear`]. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct Interrupt(Arc); impl Interrupt { + pub fn new() -> Self { + Self::default() + } + pub fn set(&self) { self.0.store(true, Ordering::Relaxed); } @@ -121,6 +143,7 @@ impl From for RunError { /// A global environment with the built-ins, in which scripts run one after another. pub struct Realm { interp: Interp, + unbroken_fuel: u64, } impl Default for Realm { @@ -138,7 +161,13 @@ impl Realm { /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it /// counted together. pub fn with_options(options: Options, limits: Limits) -> Self { - Self { interp: Interp::new(options, limits) } + Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL } + } + + /// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default + /// [`MAX_UNBROKEN_FUEL`]). + pub fn set_unbroken_fuel(&mut self, fuel: u64) { + self.unbroken_fuel = fuel; } pub fn limits(&self) -> &Limits { @@ -173,6 +202,13 @@ impl Realm { /// value of the last expression statement that ran). A realm whose built-ins could not be /// installed runs nothing and gives an internal error. pub fn run(&mut self, source: &str) -> Result { + let code = self.compile(source)?; + let result = self.interp.run_global(code, false); + self.outcome(result) + } + + /// Compiles `source` and readies the realm for a run. + fn compile(&mut self, source: &str) -> Result, RunError> { let interp = &mut self.interp; if let Some(message) = &interp.install_error { return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); @@ -181,13 +217,18 @@ impl Realm { interp.source_units = 0; interp.throw_site = None; interp.last_thrown = Value::Undefined; + interp.host_abort = None; let strict = interp.options.strict; - let code = match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) { - Ok(code) => code, - Err(CompileError::Syntax(e)) => return Err(e.into()), - Err(CompileError::Internal(m)) => return Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), - }; - let result = interp.run_global(code, false); + match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) { + Ok(code) => Ok(code), + Err(CompileError::Syntax(e)) => Err(e.into()), + Err(CompileError::Internal(m)) => Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), + } + } + + /// The end of a run: its completion value or error, with the interpreter's stacks emptied. + fn outcome(&mut self, result: Result) -> Result { + let interp = &mut self.interp; let (line, column) = interp.throw_site.take().map_or((0, 0), |(code, pc)| code.line_column(pc)); interp.stack.clear(); interp.frames.clear(); @@ -200,11 +241,84 @@ impl Realm { interp.last_thrown = v; Err(RunError { kind: RunErrorKind::Thrown, message, line, column }) } + Err(Abrupt::Limit(LimitKind::Host)) => { + let message = interp.host_abort.take().unwrap_or_else(|| format!("limit reached: {:?}", LimitKind::Host)); + Err(RunError { kind: RunErrorKind::Limit(LimitKind::Host), message, line, column }) + } Err(Abrupt::Limit(kind)) => Err(RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line, column }), Err(Abrupt::Internal(message)) => Err(RunError { kind: RunErrorKind::Internal, message, line, column }), } } + /// Begins a run that [`Realm::advance`] carries on: compiles `source` and enters it. + pub fn start(&mut self, source: &str) -> Result<(), RunError> { + let code = self.compile(source)?; + match self.interp.enter_global(code, false) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + + /// Runs the script begun with [`Realm::start`] for about `slice` units of fuel (at least one + /// instruction), and pauses where it can resume: between two instructions of the script's own + /// code. A getter, a built-in's callback or another nested run has native frames under it, so + /// it runs on to its end, for at most [`Realm::set_unbroken_fuel`] more. Once this has returned + /// [`Progress::Done`] or an error, the run is over. + pub fn advance(&mut self, slice: u64) -> Result { + let interp = &mut self.interp; + if interp.frames.is_empty() { + return Err(RunError { kind: RunErrorKind::Internal, message: "no run to advance".into(), line: 0, column: 0 }); + } + let total = interp.fuel; + let cap = slice.saturating_add(self.unbroken_fuel).min(total); + interp.fuel = cap; + let r = interp.execute_slice(cap - slice.min(cap)); + let used = cap - interp.fuel; + interp.fuel = total - used; + match r { + Ok(None) => Ok(Progress::Paused), + Ok(Some(v)) => self.outcome(Ok(v)).map(Progress::Done), + // The run's own fuel is not used up, so it was the allowance of this slice. + Err(Abrupt::Limit(LimitKind::Fuel)) if total > cap => self.outcome(Err(Abrupt::Limit(LimitKind::Unbroken))).map(Progress::Done), + Err(e) => self.outcome(Err(e)).map(Progress::Done), + } + } + + /// Is a run begun with [`Realm::start`] still going? + pub fn is_running(&self) -> bool { + !self.interp.frames.is_empty() + } + + /// The flag a host function sets to end the current slice at the next point it can pause. + pub fn pause_flag(&self) -> std::rc::Rc> { + self.interp.pause.clone() + } + + /// Has this realm stop when `interrupt` is set, in place of its own flag. + pub fn share_interrupt(&mut self, interrupt: &Interrupt) { + self.interp.interrupt = interrupt.0.clone(); + } + + /// Fuel the run has left. + pub fn fuel_left(&self) -> u64 { + self.interp.fuel + } + + /// Sets how much a value passed to or from a host function may carry. + pub fn set_data_limits(&mut self, limits: crate::DataLimits) { + self.interp.data_limits = limits; + } + + /// Adds the frozen object `name`, with `items`, to the global environment. Scripts cannot add + /// to it, change it or replace it. A heap too small for it ends with the error the realm's + /// next run would give. + pub fn define_host_object(&mut self, name: &str, items: Vec<(String, crate::HostItem)>) -> Result<(), RunError> { + match self.interp.install_host_object(name, items) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + /// Defines the global variable `name` holding the exception the last run did not catch /// (`undefined` if there was none). pub fn bind_last_thrown(&mut self, name: &str) { diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index c0139bccc..34094222b 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -28,7 +28,8 @@ const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; -/// The engine is running a long call and this request was not run (implementation-defined range). +/// The engine is running a long call or a script and this request was not run +/// (implementation-defined range). pub(crate) const SERVER_BUSY: i64 = -32003; /// How long a call runs before a request that would have to wait behind it is refused as busy @@ -435,7 +436,11 @@ impl Server { if let Some(arguments) = args.as_object() { crate::tools::check_tool_arguments(name, arguments).map_err(|e| (INVALID_PARAMS, e))?; } - Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + let result = call_tool(self.backend.as_mut(), name, &args); + match result.script_busy() { + Some(message) => Err((SERVER_BUSY, message)), + None => Ok(result.to_value()), + } } "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; diff --git a/crates/mcp/src/tests/concurrency.rs b/crates/mcp/src/tests/concurrency.rs index 7b9352e52..d291d1e9f 100644 --- a/crates/mcp/src/tests/concurrency.rs +++ b/crates/mcp/src/tests/concurrency.rs @@ -344,6 +344,23 @@ fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_sess thread.join().unwrap(); } +#[test] +fn a_change_while_a_script_runs_is_refused_as_busy() { + let mut backend = crate::Headless::with_document(); + backend.session.script_frames = true; + backend.session.execute("script.run", &json!({"source": "while (true) {}"})).unwrap(); + let mut s = Server::new(Box::new(backend)); + let call = |id: &str, params: Value| json!({"name": "command_run", "arguments": {"id": id, "params": params}}); + let reply = super::rpc(&mut s, 1, "tools/call", call("shape.rectangle", json!({"x": 0, "y": 0, "width": 1, "height": 1}))); + assert_eq!(reply["error"]["code"], -32003, "{reply}"); + assert!(reply["error"]["message"].as_str().unwrap().contains("a script is running"), "{reply}"); + let reply = super::rpc(&mut s, 2, "tools/call", json!({"name": "doc_inspect", "arguments": {}})); + assert_eq!(reply["result"]["isError"], false, "reading the document is not refused: {reply}"); + let reply = super::rpc(&mut s, 3, "tools/call", call("no.such", json!({}))); + assert_eq!(reply["result"]["isError"], true, "any other failure stays a tool error: {reply}"); + assert!(reply.get("error").is_none(), "{reply}"); +} + #[test] fn a_remote_call_stops_waiting_for_the_app_when_cancelled() { use std::io::BufRead; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 66080b6fa..93ef5749a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -36,4 +36,9 @@ impl ToolResult { pub fn to_value(&self) -> Value { json!({"content": self.content, "isError": self.is_error}) } + /// The error's text, when the engine refused the call because a script is running. + pub(crate) fn script_busy(&self) -> Option { + let text = self.content.first()?.get("text")?.as_str()?; + (self.is_error && text.ends_with(vectorcraft_engine::SCRIPT_BUSY)).then(|| text.to_string()) + } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 49775e241..81c601c1c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -53,6 +53,8 @@ impl VectorcraftApp { // The place cursor's clicks and drags place their files through the app // ([`place::place_from_cursor`]). session.place_cursor_requests = true; + // A script runs a slice per frame, so the window keeps drawing and Stop can be pressed. + session.script_frames = true; let views = session.documents().iter().map(View::of).collect(); let view_uids = session.documents().iter().map(|d| d.uid).collect(); let known = session.documents().iter().map(|d| d.uid).collect(); @@ -232,6 +234,19 @@ impl VectorcraftApp { /// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are /// called from the host's `eframe::App` impl. impl VectorcraftApp { + /// Applies the work that finished off the UI thread. It stays pending while a script runs, since + /// every change to the documents ends in a busy error until the script ends. + pub(crate) fn poll_finished_work(&mut self) { + if self.session.script_running() { + return; + } + background::poll(self); + opening::poll(self); + plugin_runs::poll(self); + trace_runs::poll(self); + placing::poll(self); + } + /// The language the UI is drawn in: the Preferences dialog's choice while it is open (so a /// change shows before OK), else the `interfaceLanguage` preference (`auto` = the system's). pub fn ui_language(&self) -> i18n::Lang { @@ -321,12 +336,12 @@ impl VectorcraftApp { // A Paste the host read before this iteration's formats were written must still wait for // them, even if the worker below finishes them during this same tick. ctx.data_mut(|d| d.insert_temp(egui::Id::unique("clipboard.pending-at-tick"), self.clipboard_publishing.is_pending())); - background::poll(self); screen_color::poll(self, ctx); - opening::poll(self); - plugin_runs::poll(self); - trace_runs::poll(self); - placing::poll(self); + self.poll_finished_work(); + crate::script_runs::poll(self); + if self.session.script_running() && !self.session.script_waiting() { + ctx.request_repaint(); + } #[cfg(not(target_arch = "wasm32"))] self.tick_link_updates(ctx, now); crate::addons::select::link_status::refresh(self, now); @@ -351,7 +366,12 @@ impl VectorcraftApp { crate::ime::close_viewport(self, ctx); self.ime_context.end_viewport(ctx.viewport_id()); unsaved::finish_pending(self); - if self.file_sheet_open() { + if self.session.script_running() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if self.ui.dialog.is_none() { + self.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, serde_json::json!({}))); + } + } else if self.file_sheet_open() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.status(picks::busy()); } else if unsaved::any_dirty(self) { diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 9fbd02327..899508021 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -179,12 +179,18 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { } right = left - gap; } - let busy: Vec<&str> = app - .background - .jobs - .iter() - .filter(|j| !j.label.is_empty() && !j.cancelled()) - .map(|j| j.label.as_str()) + let script = app.session.script_running().then_some("Running a script"); + if script.is_some() { + let left = (right - 52.0).max(area.left()); + let stop = egui::Rect::from_min_max(egui::pos2(left, area.top() + 1.0), egui::pos2(right, area.bottom() - 1.0)); + if ui.put(stop, egui::Button::new(tl!("Stop")).small()).clicked() { + app.session.script_interrupt().set(); + } + right = left - gap; + } + let busy: Vec<&str> = script + .into_iter() + .chain(app.background.jobs.iter().filter(|j| !j.label.is_empty() && !j.cancelled()).map(|j| j.label.as_str())) .chain(app.opening.pending.iter().map(|p| p.label.as_str())) .chain(app.placing.pending.iter().map(|p| p.label.as_str())) .collect(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6af51d95a..dce986df6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -234,6 +234,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { fields.remove("floating_panels"); } json!({ + "scriptRunning": app.session.script_running(), + "scriptReport": app.session.script_report(), "tool": app.session.tool_id(), "toolOptions": app.session.tool_options(), "ui": ui, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1f59647c1..b5100a2e8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -109,6 +109,7 @@ pub mod save_style_library; pub mod save_swatch_library; pub mod save_symbol_library; pub mod script_check; +pub mod script_run; pub mod shape_builder_options; pub(crate) mod shapes; pub mod shortcut_import; @@ -404,6 +405,9 @@ registry! { PluginImport: [plugin::IMPORT_KIND] => plugin::IMPORT_SPEC, PluginInstall: [plugin::INSTALL_KIND] => plugin::INSTALL_SPEC, ScriptCheck: [script_check::KIND] => script_check::SPEC, + ScriptRun: [script_run::KIND] => script_run::SPEC, + ScriptGrowth: [script_run::GROWTH_KIND] => script_run::GROWTH_SPEC, + ScriptClose: [script_run::CLOSE_KIND] => script_run::CLOSE_SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, @@ -570,6 +574,10 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == safe_mode::STOPPED { safe_mode::cancel_stopped(app); } + // The growth question's Cancel button is its Stop button. + if d.kind == script_run::GROWTH_KIND { + app.session.script_request_stop(); + } // Toolbar-only Flare options start no preview and must not cancel a gesture // that was already active before opening them. The generic parameter dialog has a spec // without a preview, yet previews some commands (Grid Repeat Options) when asked to. diff --git a/crates/ui-egui/src/dialogs/script_run.rs b/crates/ui-egui/src/dialogs/script_run.rs new file mode 100644 index 000000000..2ef43cb8f --- /dev/null +++ b/crates/ui-egui/src/dialogs/script_run.rs @@ -0,0 +1,147 @@ +//! File › Scripts › Other Script…: choosing a JavaScript file and the warning before it runs +//! (`script.run` runs it), the question a run that keeps adding art asks, and the question quitting +//! asks while a script runs. +//! +//! `ui.runScript {path? | source?, name?}` shows the warning for that script; without either it +//! asks for a `.js` or `.jsx` file (the web opens the browser's file picker). With Preferences › +//! General › Show External Script Warning off, the chosen script runs at once. Warning fields: +//! `name`, `script` (`script.run`'s params) and `dontShow` (true: turn the warning off). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::scripts::{RUN_EXTS, TOO_LARGE, runs}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{FilePick, VectorcraftApp, widgets}; + +/// The dialog kind of the warning before a script runs. +pub const KIND: &str = "scriptRun"; +/// The dialog kind of the question a run asks when it has added a great deal. +pub const GROWTH_KIND: &str = "scriptGrowth"; +/// The dialog kind of the question quitting asks while a script runs. +pub const CLOSE_KIND: &str = "scriptClose"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Run Script").into(), + body, + confirm, + ok: Some("Run"), + min_width: 420.0, + max_width: Some(520.0), + ..DialogSpec::FORM +}; + +pub(super) const GROWTH_SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("A Script Is Running").into(), + body: |_, ui, _| { + ui.label(tl!("This script has added a great many objects. Continue, or stop it and undo what it did?")); + false + }, + confirm: growth_confirm, + ok: Some("Continue"), + cancel: Some("Stop"), + min_width: 420.0, + max_width: Some(520.0), + ..DialogSpec::FORM +}; + +pub(super) const CLOSE_SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("A Script Is Running").into(), + body: |_, ui, _| { + ui.label(tl!("Stop the script and undo what it did? Then quit again.")); + false + }, + confirm: close_confirm, + ok: Some("Stop"), + min_width: 420.0, + max_width: Some(520.0), + ..DialogSpec::FORM +}; + +/// A file the web's picker delivered: its name and bytes, or `None` for a file over +/// [`vectorcraft_engine::script::MAX_SOURCE_BYTES`], which is not read. +pub type ScriptInbox = std::sync::Arc>)>>>; + +/// Shows the warning for the next script the web's picker delivered, once no dialog is open. A +/// picker that was canceled delivers nothing. +pub(crate) fn drain(app: &mut VectorcraftApp) { + if app.ui.dialog.is_some() { + return; + } + let Some((name, bytes)) = app.services.script_inbox.as_ref().and_then(|q| q.lock().unwrap_or_else(|e| e.into_inner()).pop()) else { return }; + let result = match bytes.as_deref().map(std::str::from_utf8) { + None => Err(TOO_LARGE.to_string()), + Some(Ok(source)) => chosen(app, json!({"source": source, "name": name})), + Some(Err(_)) => Err("the script is not UTF-8 text".to_string()), + }; + if let Err(e) = result { + app.status(e); + } +} + +/// `ui.runScript`: the script at `path` or given as `source`; without either, pick a file. +pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result { + if p.get("path").is_some() || p.get("source").is_some() { + return chosen(app, p.clone()); + } + if let Some(pick) = app.services.script_async.as_mut() { + pick(); + return Ok(Value::Null); + } + let pick = FilePick { filters: vec![FilePick::filter("Scripts", RUN_EXTS)], ..Default::default() }; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; + chosen(app, json!({ "path": path })) +} + +/// A script was chosen: `params` are `script.run`'s. It runs after the warning, when that is on. +pub(crate) fn chosen(app: &mut VectorcraftApp, params: Value) -> Result { + let path = params.get("path").and_then(Value::as_str); + if path.is_some_and(|path| !runs(path)) { + return Err("Choose a .js or .jsx script".into()); + } + if !app.session.prefs.show_external_script_warning { + return app.run("script.run", params); + } + let name = params.get("name").and_then(Value::as_str).or(path).map_or_else(|| "script".to_string(), vectorcraft_engine::cmd::fileio::file_name); + app.ui.dialog = Some(Dialog::new(KIND, json!({"name": name, "script": params, "dontShow": false}))); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(d.fields.get("name").and_then(Value::as_str).unwrap_or_default()).font(theme::semibold(12.5)).color(t.text_strong)); + ui.add_space(4.0); + ui.label(tl!("A script can change the open documents and can write files through the commands it runs. Run only scripts you trust.")); + ui.add_space(8.0); + let dont_show = d.fields.get("dontShow").and_then(Value::as_bool).unwrap_or(false); + if widgets::check(ui, tl!("Do not show this warning again"), dont_show, true) { + d.fields.insert("dontShow".into(), json!(!dont_show)); + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.fields.get("dontShow").and_then(Value::as_bool).unwrap_or(false) { + app.run("prefs.set", json!({"key": "showExternalScriptWarning", "value": false}))?; + } + let script = d.fields.get("script").cloned().unwrap_or(Value::Null); + app.ui.dialog = None; + app.run("script.run", script) +} + +fn growth_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { + app.ui.dialog = None; + app.session.script_continue(); + Ok(Value::Null) +} + +fn close_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { + app.ui.dialog = None; + if app.session.script_abandon() { + let error = app.session.script_failure_text().unwrap_or_default(); + crate::script_runs::failed(app, error); + app.sync_views(); + } + Ok(Value::Null) +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b7006..3f6a08046 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3955,6 +3955,15 @@ axis Both И двете @msg Combine Slices: the slices have no bounds Обединяване на парчета: парчетата нямат граници @msg Convert to Artboards: selection has no area Преобразуване в работни области: селекцията няма площ @msg Convert to Shape: no rectangles or ellipses selected Преобразуване в фигура: няма избрани правоъгълници или елипси + Run Изпълни + Run Script Изпълни скрипт + Stop Script Спри скрипта + Show External Script Warning Показвай предупреждение за външни скриптове + A Script Is Running Изпълнява се скрипт + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скриптът може да променя отворените документи и да записва файлове чрез командите, които изпълнява. Изпълнявайте само скриптове, на които имате доверие. + Do not show this warning again Не показвай повече това предупреждение + Stop the script and undo what it did? Then quit again. Да се спре ли скриптът и да се отменят ли промените му? След това излезте отново. + This script has added a great many objects. Continue, or stop it and undo what it did? Този скрипт добави изключително много обекти. Да продължи ли, или да се спре и да се отменят промените му? @msg Copied the script report Копиран е отчетът за скрипта @msg Corners edits the corners of a path Ъглите редактират ъглите на пътя @msg Couldn't copy to the system clipboard: {e} Неуспешно копиране в системния клипборд: {e} diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a655..6f502d59a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4320,6 +4320,15 @@ imageTrace Outline Obris @msg Combine Slices: the slices have no bounds Kombinuj isječke: isječci nemaju granice @msg Convert to Artboards: selection has no area Pretvori u radne ploče: odabir nema površinu @msg Convert to Shape: no rectangles or ellipses selected Pretvori u oblik: nisu odabrani pravougaonici ni elipse + Run Pokreni + Run Script Pokreni skriptu + Stop Script Zaustavi skriptu + Show External Script Warning Prikaži upozorenje za vanjske skripte + A Script Is Running Skripta se izvršava + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skripta može mijenjati otvorene dokumente i pisati datoteke putem naredbi koje pokreće. Pokrećite samo skripte kojima vjerujete. + Do not show this warning again Ne prikazuj više ovo upozorenje + Stop the script and undo what it did? Then quit again. Zaustaviti skriptu i poništiti ono što je uradila? Zatim ponovo izađite. + This script has added a great many objects. Continue, or stop it and undo what it did? Ova skripta je dodala veoma mnogo objekata. Nastaviti, ili je zaustaviti i poništiti ono što je uradila? @msg Copied the script report Izvještaj skripte kopiran @msg Corners edits the corners of a path Uglovi uređuje uglove putanje @msg Couldn't copy to the system clipboard: {e} Nije moguće kopirati u sistemski međuspremnik: {e} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f..74261f1ac 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5155,6 +5155,27 @@ digits Malayalam Malaiàlam @msg control must be fillStroke, coloring, drawMode or screenMode control ha de ser fillStroke, coloring, drawMode o screenMode @msg Convert to Artboards: selection has no area Converteix en taulers: la selecció no té àrea @msg Convert to Shape: no rectangles or ellipses selected Converteix en forma: no s’ha seleccionat cap rectangle ni el·lipse + Run Executa + Run Script Executa l'script + Stop Script Atura l'script + Show External Script Warning Mostra l'avís dels scripts externs + A Script Is Running S'està executant un script + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script pot canviar els documents oberts i escriure fitxers mitjançant les ordres que executa. Executeu només scripts de confiança. + Do not show this warning again No tornis a mostrar aquest avís + Stop the script and undo what it did? Then quit again. Voleu aturar l'script i desfer el que ha fet? Després, torneu a sortir. + This script has added a great many objects. Continue, or stop it and undo what it did? Aquest script ha afegit moltíssims objectes. Voleu continuar, o aturar-lo i desfer el que ha fet? +@msg Choose a .js or .jsx script Trieu un script .js o .jsx +@msg Ran {name} S'ha executat {name} +@msg The script was stopped and its changes were undone L'script s'ha aturat i s'han desfet els seus canvis +@msg a script is running: stop it or wait for it to finish s'està executant un script: atureu-lo o espereu que acabi +@msg this is a compiled script, which cannot be run; use its source text aquest és un script compilat, que no es pot executar; useu el seu text font +@msg the script is not UTF-8 text l'script no és text UTF-8 +@msg the script is too large to run l'script és massa gran per executar-lo +@msg a drag or an undo group is open; finish it before running a script hi ha un arrossegament o un grup de desfer obert; acabeu-lo abans d'executar un script +@msg the script left a document that cannot be kept l'script ha deixat un document que no es pot conservar +@msg the script's run is no longer available l'execució de l'script ja no està disponible +@msg a recovery copy could not be written, so the script was not run no s'ha pogut escriure una còpia de recuperació, per tant l'script no s'ha executat +@msg Running a script S'està executant un script @msg Copied the script report S’ha copiat l’informe de l’script @msg Corners edits the corners of a path Cantonades edita les cantonades d’un traçat @msg could not decode image: {0} no s’ha pogut descodificar la imatge: {0} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2..da6b54364 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4378,6 +4378,27 @@ imageTrace Outline Obrys @msg Combine Slices: the slices have no bounds Spojit řezy: řezy nemají hranice @msg Convert to Artboards: selection has no area Převést na pracovní plochy: výběr nemá žádnou plochu @msg Convert to Shape: no rectangles or ellipses selected Převést na tvar: nejsou vybrány žádné obdélníky ani elipsy + Run Spustit + Run Script Spustit skript + Stop Script Zastavit skript + Show External Script Warning Zobrazit upozornění u externích skriptů + A Script Is Running Běží skript + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skript může měnit otevřené dokumenty a prostřednictvím příkazů, které spouští, zapisovat soubory. Spouštějte jen skripty, kterým důvěřujete. + Do not show this warning again Toto upozornění již nezobrazovat + Stop the script and undo what it did? Then quit again. Zastavit skript a vrátit jeho změny? Potom aplikaci ukončete znovu. + This script has added a great many objects. Continue, or stop it and undo what it did? Tento skript přidal velmi mnoho objektů. Pokračovat, nebo jej zastavit a vrátit jeho změny? +@msg Choose a .js or .jsx script Vyberte skript .js nebo .jsx +@msg Ran {name} Spuštěno: {name} +@msg The script was stopped and its changes were undone Skript byl zastaven a jeho změny byly vráceny +@msg a script is running: stop it or wait for it to finish běží skript: zastavte jej nebo počkejte na jeho dokončení +@msg this is a compiled script, which cannot be run; use its source text toto je zkompilovaný skript, který nelze spustit; použijte jeho zdrojový text +@msg the script is not UTF-8 text skript není text v kódování UTF-8 +@msg the script is too large to run skript je příliš velký na spuštění +@msg a drag or an undo group is open; finish it before running a script je otevřené tažení nebo skupina kroků zpět; před spuštěním skriptu ji dokončete +@msg the script left a document that cannot be kept skript zanechal dokument, který nelze zachovat +@msg the script's run is no longer available běh skriptu již není k dispozici +@msg a recovery copy could not be written, so the script was not run záložní kopii se nepodařilo zapsat, proto skript nebyl spuštěn +@msg Running a script Běží skript @msg Copied the script report Zpráva o skriptu zkopírována @msg Corners edits the corners of a path Rohy upravuje rohy cesty @msg Couldn't copy to the system clipboard: {e} Nelze kopírovat do systémové schránky: {e} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718b..e7bd68fb0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4074,6 +4074,15 @@ imageTrace Outline Kontur @msg Combine Slices: the slices have no bounds Kombinér udsnit: udsnittene har ingen grænser @msg Convert to Artboards: selection has no area Konvertér til tegneflader: markeringen har intet areal @msg Convert to Shape: no rectangles or ellipses selected Konvertér til figur: ingen rektangler eller ellipser er markeret + Run Kør + Run Script Kør script + Stop Script Stop script + Show External Script Warning Vis advarsel om eksterne scripts + A Script Is Running Et script kører + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Et script kan ændre de åbne dokumenter og skrive filer gennem de kommandoer, det kører. Kør kun scripts, du stoler på. + Do not show this warning again Vis ikke denne advarsel igen + Stop the script and undo what it did? Then quit again. Stop scriptet og fortryd det, det har gjort? Afslut derefter igen. + This script has added a great many objects. Continue, or stop it and undo what it did? Dette script har tilføjet rigtig mange objekter. Fortsæt, eller stop det og fortryd det, det har gjort? @msg Copied the script report Kopierede scriptrapporten @msg Corners edits the corners of a path Hjørner redigerer hjørnerne på en kurve @msg Couldn't copy to the system clipboard: {e} Kunne ikke kopiere til systemets udklipsholder: {e} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238..9bcec31d8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3983,6 +3983,27 @@ theme Appearance Erscheinungsbild @msg Combine Slices: the slices have no bounds Slices kombinieren: Die Slices haben keine Begrenzung @msg Convert to Artboards: selection has no area In Zeichenflächen umwandeln: Die Auswahl hat keine Fläche @msg Convert to Shape: no rectangles or ellipses selected In Form umwandeln: keine Rechtecke oder Ellipsen ausgewählt + Run Ausführen + Run Script Skript ausführen + Stop Script Skript stoppen + Show External Script Warning Warnung bei externen Skripten anzeigen + A Script Is Running Ein Skript wird ausgeführt + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ein Skript kann die geöffneten Dokumente ändern und über die ausgeführten Befehle Dateien schreiben. Führen Sie nur Skripte aus, denen Sie vertrauen. + Do not show this warning again Diese Warnung nicht mehr anzeigen + Stop the script and undo what it did? Then quit again. Skript stoppen und seine Änderungen rückgängig machen? Beenden Sie danach erneut. + This script has added a great many objects. Continue, or stop it and undo what it did? Dieses Skript hat sehr viele Objekte hinzugefügt. Fortfahren oder das Skript stoppen und seine Änderungen rückgängig machen? +@msg Choose a .js or .jsx script Wählen Sie ein .js- oder .jsx-Skript +@msg Ran {name} {name} ausgeführt +@msg The script was stopped and its changes were undone Das Skript wurde gestoppt und seine Änderungen wurden rückgängig gemacht +@msg a script is running: stop it or wait for it to finish ein Skript wird ausgeführt: stoppen Sie es oder warten Sie, bis es fertig ist +@msg this is a compiled script, which cannot be run; use its source text dies ist ein kompiliertes Skript, das nicht ausgeführt werden kann; verwenden Sie seinen Quelltext +@msg the script is not UTF-8 text das Skript ist kein UTF-8-Text +@msg the script is too large to run das Skript ist zu groß zum Ausführen +@msg a drag or an undo group is open; finish it before running a script ein Ziehvorgang oder eine Rückgängig-Gruppe ist offen; schließen Sie sie ab, bevor Sie ein Skript ausführen +@msg the script left a document that cannot be kept das Skript hat ein Dokument hinterlassen, das nicht behalten werden kann +@msg the script's run is no longer available der Lauf des Skripts ist nicht mehr verfügbar +@msg a recovery copy could not be written, so the script was not run eine Wiederherstellungskopie konnte nicht geschrieben werden, daher wurde das Skript nicht ausgeführt +@msg Running a script Skript wird ausgeführt @msg Copied the script report Skriptbericht kopiert @msg Corners edits the corners of a path „Ecken“ bearbeitet die Ecken eines Pfads @msg Couldn't copy to the system clipboard: {e} In die Systemzwischenablage konnte nicht kopiert werden: {e} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6e..c8a7ca759 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2233,6 +2233,27 @@ axis Both Και τα δύο @msg Combine Slices: the slices have no bounds Συνδυασμός τομών: οι τομές δεν έχουν όρια @msg Convert to Artboards: selection has no area Μετατροπή σε πίνακες σχεδίασης: η επιλογή δεν έχει επιφάνεια @msg Convert to Shape: no rectangles or ellipses selected Μετατροπή σε σχήμα: δεν έχουν επιλεγεί ορθογώνια ή ελλείψεις + Run Εκτέλεση + Run Script Εκτέλεση σεναρίου + Stop Script Διακοπή σεναρίου + Show External Script Warning Εμφάνιση προειδοποίησης για εξωτερικά σενάρια + A Script Is Running Εκτελείται ένα σενάριο + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ένα σενάριο μπορεί να αλλάξει τα ανοιχτά έγγραφα και να γράψει αρχεία μέσω των εντολών που εκτελεί. Εκτελείτε μόνο σενάρια που εμπιστεύεστε. + Do not show this warning again Να μην εμφανιστεί ξανά αυτή η προειδοποίηση + Stop the script and undo what it did? Then quit again. Διακοπή του σεναρίου και αναίρεση όσων έκανε; Έπειτα κάντε ξανά έξοδο. + This script has added a great many objects. Continue, or stop it and undo what it did? Αυτό το σενάριο πρόσθεσε πάρα πολλά αντικείμενα. Συνέχεια, ή διακοπή και αναίρεση όσων έκανε; +@msg Choose a .js or .jsx script Επιλέξτε ένα σενάριο .js ή .jsx +@msg Ran {name} Εκτελέστηκε: {name} +@msg The script was stopped and its changes were undone Το σενάριο διακόπηκε και οι αλλαγές του αναιρέθηκαν +@msg a script is running: stop it or wait for it to finish εκτελείται ένα σενάριο: διακόψτε το ή περιμένετε να τελειώσει +@msg this is a compiled script, which cannot be run; use its source text αυτό είναι μεταγλωττισμένο σενάριο που δεν μπορεί να εκτελεστεί· χρησιμοποιήστε το πηγαίο κείμενό του +@msg the script is not UTF-8 text το σενάριο δεν είναι κείμενο UTF-8 +@msg the script is too large to run το σενάριο είναι πολύ μεγάλο για εκτέλεση +@msg a drag or an undo group is open; finish it before running a script είναι ανοιχτή μια μεταφορά ή ομάδα αναίρεσης· ολοκληρώστε την πριν εκτελέσετε σενάριο +@msg the script left a document that cannot be kept το σενάριο άφησε ένα έγγραφο που δεν μπορεί να διατηρηθεί +@msg the script's run is no longer available η εκτέλεση του σεναρίου δεν είναι πλέον διαθέσιμη +@msg a recovery copy could not be written, so the script was not run δεν ήταν δυνατή η εγγραφή αντιγράφου ανάκτησης, οπότε το σενάριο δεν εκτελέστηκε +@msg Running a script Εκτελείται σενάριο @msg Copied the script report Η αναφορά του σεναρίου αντιγράφηκε @msg Corners edits the corners of a path Η εντολή Γωνίες επεξεργάζεται τις γωνίες μιας διαδρομής @msg Couldn't copy to the system clipboard: {e} Δεν ήταν δυνατή η αντιγραφή στο πρόχειρο συστήματος: {e} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d5059411..692632fec 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4068,6 +4068,27 @@ axis Both Ambos @msg Combine Slices: the slices have no bounds Combinar sectores: los sectores no tienen límites @msg Convert to Artboards: selection has no area Convertir en mesas de trabajo: la selección no tiene área @msg Convert to Shape: no rectangles or ellipses selected Convertir en forma: no hay rectángulos ni elipses seleccionados + Run Ejecutar + Run Script Ejecutar script + Stop Script Detener script + Show External Script Warning Mostrar advertencia de scripts externos + A Script Is Running Se está ejecutando un script + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script puede cambiar los documentos abiertos y escribir archivos mediante los comandos que ejecuta. Ejecute solo scripts de confianza. + Do not show this warning again No volver a mostrar esta advertencia + Stop the script and undo what it did? Then quit again. ¿Detener el script y deshacer lo que hizo? Después, vuelva a salir. + This script has added a great many objects. Continue, or stop it and undo what it did? Este script ha añadido muchísimos objetos. ¿Continuar, o detenerlo y deshacer lo que hizo? +@msg Choose a .js or .jsx script Elija un script .js o .jsx +@msg Ran {name} Se ejecutó {name} +@msg The script was stopped and its changes were undone El script se detuvo y sus cambios se deshicieron +@msg a script is running: stop it or wait for it to finish se está ejecutando un script: deténgalo o espere a que termine +@msg this is a compiled script, which cannot be run; use its source text este es un script compilado, que no se puede ejecutar; use su texto fuente +@msg the script is not UTF-8 text el script no es texto UTF-8 +@msg the script is too large to run el script es demasiado grande para ejecutarlo +@msg a drag or an undo group is open; finish it before running a script hay un arrastre o un grupo de deshacer abierto; termínelo antes de ejecutar un script +@msg the script left a document that cannot be kept el script dejó un documento que no se puede conservar +@msg the script's run is no longer available la ejecución del script ya no está disponible +@msg a recovery copy could not be written, so the script was not run no se pudo escribir una copia de recuperación, así que el script no se ejecutó +@msg Running a script Ejecutando un script @msg Copied the script report Se copió el informe del script @msg Corners edits the corners of a path Esquinas edita las esquinas de un trazado @msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111..c242606a6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4292,6 +4292,27 @@ imageTrace Outline Ääriviiva @msg Combine Slices: the slices have no bounds Yhdistä viipaleet: viipaleilla ei ole rajoja @msg Convert to Artboards: selection has no area Muunna piirtoalustoiksi: valinnalla ei ole pinta-alaa @msg Convert to Shape: no rectangles or ellipses selected Muunna muodoksi: suorakulmioita tai ellipsejä ei ole valittu + Run Suorita + Run Script Suorita komentosarja + Stop Script Pysäytä komentosarja + Show External Script Warning Näytä ulkoisten komentosarjojen varoitus + A Script Is Running Komentosarja on käynnissä + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Komentosarja voi muuttaa avoimia asiakirjoja ja kirjoittaa tiedostoja suorittamillaan komennoilla. Suorita vain komentosarjoja, joihin luotat. + Do not show this warning again Älä näytä tätä varoitusta uudelleen + Stop the script and undo what it did? Then quit again. Pysäytetäänkö komentosarja ja kumotaan sen muutokset? Lopeta sitten uudelleen. + This script has added a great many objects. Continue, or stop it and undo what it did? Tämä komentosarja on lisännyt hyvin paljon objekteja. Jatketaanko, vai pysäytetäänkö se ja kumotaan sen muutokset? +@msg Choose a .js or .jsx script Valitse .js- tai .jsx-komentosarja +@msg Ran {name} Suoritettu: {name} +@msg The script was stopped and its changes were undone Komentosarja pysäytettiin ja sen muutokset kumottiin +@msg a script is running: stop it or wait for it to finish komentosarja on käynnissä: pysäytä se tai odota sen valmistumista +@msg this is a compiled script, which cannot be run; use its source text tämä on käännetty komentosarja, jota ei voi suorittaa; käytä sen lähdetekstiä +@msg the script is not UTF-8 text komentosarja ei ole UTF-8-tekstiä +@msg the script is too large to run komentosarja on liian suuri suoritettavaksi +@msg a drag or an undo group is open; finish it before running a script vetäminen tai kumousryhmä on kesken; viimeistele se ennen komentosarjan suorittamista +@msg the script left a document that cannot be kept komentosarja jätti asiakirjan, jota ei voi säilyttää +@msg the script's run is no longer available komentosarjan suoritus ei ole enää käytettävissä +@msg a recovery copy could not be written, so the script was not run palautuskopiota ei voitu kirjoittaa, joten komentosarjaa ei suoritettu +@msg Running a script Suoritetaan komentosarjaa @msg Copied the script report Komentosarjan raportti kopioitiin @msg Corners edits the corners of a path Kulmat-toiminto muokkaa polun kulmia @msg Couldn't copy to the system clipboard: {e} Järjestelmän leikepöydälle ei voitu kopioida: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e..7e8b075db 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4057,6 +4057,27 @@ axis Both Les deux @msg Combine Slices: the slices have no bounds Combiner les tranches : les tranches n’ont pas de limites @msg Convert to Artboards: selection has no area Convertir en plans de travail : la sélection n’a pas de surface @msg Convert to Shape: no rectangles or ellipses selected Convertir en forme : aucun rectangle ni aucune ellipse sélectionné + Run Exécuter + Run Script Exécuter le script + Stop Script Arrêter le script + Show External Script Warning Afficher l’avertissement des scripts externes + A Script Is Running Un script est en cours d’exécution + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script peut modifier les documents ouverts et écrire des fichiers au moyen des commandes qu’il exécute. N’exécutez que des scripts de confiance. + Do not show this warning again Ne plus afficher cet avertissement + Stop the script and undo what it did? Then quit again. Arrêter le script et annuler ce qu’il a fait ? Quittez ensuite à nouveau. + This script has added a great many objects. Continue, or stop it and undo what it did? Ce script a ajouté un très grand nombre d’objets. Continuer, ou l’arrêter et annuler ce qu’il a fait ? +@msg Choose a .js or .jsx script Choisissez un script .js ou .jsx +@msg Ran {name} {name} exécuté +@msg The script was stopped and its changes were undone Le script a été arrêté et ses modifications ont été annulées +@msg a script is running: stop it or wait for it to finish un script est en cours d’exécution : arrêtez-le ou attendez qu’il se termine +@msg this is a compiled script, which cannot be run; use its source text ce script est compilé et ne peut pas être exécuté ; utilisez son texte source +@msg the script is not UTF-8 text le script n’est pas du texte UTF-8 +@msg the script is too large to run le script est trop volumineux pour être exécuté +@msg a drag or an undo group is open; finish it before running a script un glissement ou un groupe d’annulation est ouvert ; terminez-le avant d’exécuter un script +@msg the script left a document that cannot be kept le script a laissé un document qui ne peut pas être conservé +@msg the script's run is no longer available l’exécution du script n’est plus disponible +@msg a recovery copy could not be written, so the script was not run une copie de récupération n’a pas pu être écrite, le script n’a donc pas été exécuté +@msg Running a script Exécution d’un script @msg Copied the script report Rapport du script copié @msg Corners edits the corners of a path Angles modifie les angles d’un tracé @msg Couldn't copy to the system clipboard: {e} Impossible de copier dans le presse-papiers du système : {e} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03dec..9c5f73e18 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6129,6 +6129,27 @@ antiAlias Sharp शार्प @msg control must be fillStroke, coloring, drawMode or screenMode control को fillStroke, coloring, drawMode या screenMode होना चाहिए @msg Convert to Artboards: selection has no area आर्टबोर्ड में कनवर्ट करें: सिलेक्शन में कोई क्षेत्र नहीं है @msg Convert to Shape: no rectangles or ellipses selected आकार में कनवर्ट करें: कोई आयत या अंडाकार सिलेक्शन नहीं + Run चलाएँ + Run Script स्क्रिप्ट चलाएँ + Stop Script स्क्रिप्ट रोकें + Show External Script Warning बाहरी स्क्रिप्ट की चेतावनी दिखाएँ + A Script Is Running एक स्क्रिप्ट चल रही है + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. स्क्रिप्ट खुले दस्तावेज़ बदल सकती है और अपने चलाए कमांड से फ़ाइलें लिख सकती है। केवल भरोसेमंद स्क्रिप्ट चलाएँ। + Do not show this warning again यह चेतावनी फिर न दिखाएँ + Stop the script and undo what it did? Then quit again. स्क्रिप्ट रोककर उसके बदलाव पूर्ववत करें? फिर दोबारा बाहर निकलें। + This script has added a great many objects. Continue, or stop it and undo what it did? इस स्क्रिप्ट ने बहुत सारे ऑब्जेक्ट जोड़े हैं। जारी रखें, या इसे रोककर इसके बदलाव पूर्ववत करें? +@msg Choose a .js or .jsx script .js या .jsx स्क्रिप्ट चुनें +@msg Ran {name} {name} चलाई गई +@msg The script was stopped and its changes were undone स्क्रिप्ट रोकी गई और उसके बदलाव पूर्ववत किए गए +@msg a script is running: stop it or wait for it to finish एक स्क्रिप्ट चल रही है: उसे रोकें या पूरा होने की प्रतीक्षा करें +@msg this is a compiled script, which cannot be run; use its source text यह कंपाइल की हुई स्क्रिप्ट है, जिसे चलाया नहीं जा सकता; इसका स्रोत पाठ इस्तेमाल करें +@msg the script is not UTF-8 text स्क्रिप्ट UTF-8 पाठ नहीं है +@msg the script is too large to run स्क्रिप्ट चलाने के लिए बहुत बड़ी है +@msg a drag or an undo group is open; finish it before running a script कोई ड्रैग या पूर्ववत समूह खुला है; स्क्रिप्ट चलाने से पहले उसे पूरा करें +@msg the script left a document that cannot be kept स्क्रिप्ट ने ऐसा दस्तावेज़ छोड़ा है जिसे रखा नहीं जा सकता +@msg the script's run is no longer available स्क्रिप्ट का रन अब उपलब्ध नहीं है +@msg a recovery copy could not be written, so the script was not run रिकवरी प्रति नहीं लिखी जा सकी, इसलिए स्क्रिप्ट नहीं चलाई गई +@msg Running a script स्क्रिप्ट चल रही है @msg Copied the script report Copied स्क्रिप्ट रिपोर्ट @msg copy needs path: the file to copy into the document's folder copy: प्रतिलिपि की जरूरत है पाथ: फ़ाइल को डॉक्यूमेंट के फ़ोल्डर में कॉपी करने के लिए @msg copy takes images only: relink placed documents where their files are copy: प्रतिलिपि केवल इमेज को लेती है: डॉक्यूमेंट्स को फिर से लिंक करें जहां उनकी फ़ाइलें हैं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37..16451a4ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4863,6 +4863,27 @@ imageTrace Outline Körvonal @msg compressed (RLE) bitmaps can't be read yet a tömörített (RLE) bitképek még nem olvashatók @msg Convert to Artboards: selection has no area Átalakítás rajztáblákká: a kijelölésnek nincs területe @msg Convert to Shape: no rectangles or ellipses selected Átalakítás alakzattá: nincs kijelölt téglalap vagy ellipszis + Run Futtatás + Run Script Parancsfájl futtatása + Stop Script Parancsfájl leállítása + Show External Script Warning Figyelmeztetés külső parancsfájlok előtt + A Script Is Running Egy parancsfájl fut + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. A parancsfájl módosíthatja a megnyitott dokumentumokat, és a futtatott parancsokkal fájlokat írhat. Csak megbízható parancsfájlokat futtasson. + Do not show this warning again Ne jelenjen meg többé ez a figyelmeztetés + Stop the script and undo what it did? Then quit again. Leállítja a parancsfájlt, és visszavonja a módosításait? Ezután lépjen ki újra. + This script has added a great many objects. Continue, or stop it and undo what it did? Ez a parancsfájl nagyon sok objektumot adott hozzá. Folytatja, vagy leállítja és visszavonja a módosításait? +@msg Choose a .js or .jsx script Válasszon .js vagy .jsx parancsfájlt +@msg Ran {name} Lefutott: {name} +@msg The script was stopped and its changes were undone A parancsfájl leállt, a módosításai visszavonva +@msg a script is running: stop it or wait for it to finish egy parancsfájl fut: állítsa le, vagy várja meg, hogy befejeződjön +@msg this is a compiled script, which cannot be run; use its source text ez lefordított parancsfájl, amely nem futtatható; használja a forrásszövegét +@msg the script is not UTF-8 text a parancsfájl nem UTF-8 szöveg +@msg the script is too large to run a parancsfájl túl nagy a futtatáshoz +@msg a drag or an undo group is open; finish it before running a script húzás vagy visszavonási csoport van folyamatban; fejezze be a parancsfájl futtatása előtt +@msg the script left a document that cannot be kept a parancsfájl olyan dokumentumot hagyott hátra, amely nem tartható meg +@msg the script's run is no longer available a parancsfájl futása már nem érhető el +@msg a recovery copy could not be written, so the script was not run a helyreállítási másolatot nem sikerült kiírni, ezért a parancsfájl nem futott le +@msg Running a script Parancsfájl futtatása @msg Copied the script report Szkriptjelentés másolva @msg Corners edits the corners of a path a Sarkok parancs egy útvonal sarkait szerkeszti @msg could not decode image: {0} a kép nem dekódolható: {0} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f..a09b4ed61 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4074,6 +4074,15 @@ imageTrace Outline Kontur @msg Combine Slices: the slices have no bounds Gabungkan irisan: irisan tidak memiliki batas @msg Convert to Artboards: selection has no area Konversi ke artboard: seleksi tidak memiliki area @msg Convert to Shape: no rectangles or ellipses selected Konversi ke bentuk: tidak ada persegi panjang atau elips yang dipilih + Run Jalankan + Run Script Jalankan Skrip + Stop Script Hentikan Skrip + Show External Script Warning Tampilkan Peringatan Skrip Eksternal + A Script Is Running Skrip Sedang Berjalan + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrip dapat mengubah dokumen yang terbuka dan menulis file melalui perintah yang dijalankannya. Jalankan hanya skrip yang Anda percayai. + Do not show this warning again Jangan tampilkan peringatan ini lagi + Stop the script and undo what it did? Then quit again. Hentikan skrip dan batalkan yang telah dilakukannya? Lalu keluar lagi. + This script has added a great many objects. Continue, or stop it and undo what it did? Skrip ini telah menambahkan sangat banyak objek. Lanjutkan, atau hentikan dan batalkan yang telah dilakukannya? @msg Copied the script report Berhasil menyalin laporan skrip @msg Corners edits the corners of a path Corners menyunting sudut jalur @msg Couldn't copy to the system clipboard: {e} Tidak dapat menyalin ke papan klip sistem: {e} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272..762f4093a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4110,6 +4110,27 @@ axis Both Entrambi @msg Combine Slices: the slices have no bounds Combina sezioni: le sezioni non hanno limiti @msg Convert to Artboards: selection has no area Converti in tavole da disegno: la selezione non ha area @msg Convert to Shape: no rectangles or ellipses selected Converti in forma: nessun rettangolo o ellisse selezionato + Run Esegui + Run Script Esegui script + Stop Script Interrompi script + Show External Script Warning Mostra avviso per gli script esterni + A Script Is Running È in esecuzione uno script + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Uno script può modificare i documenti aperti e scrivere file tramite i comandi che esegue. Esegui solo script di cui ti fidi. + Do not show this warning again Non mostrare più questo avviso + Stop the script and undo what it did? Then quit again. Interrompere lo script e annullare ciò che ha fatto? Poi esci di nuovo. + This script has added a great many objects. Continue, or stop it and undo what it did? Questo script ha aggiunto moltissimi oggetti. Continuare, oppure interromperlo e annullare ciò che ha fatto? +@msg Choose a .js or .jsx script Scegli uno script .js o .jsx +@msg Ran {name} {name} eseguito +@msg The script was stopped and its changes were undone Lo script è stato interrotto e le sue modifiche sono state annullate +@msg a script is running: stop it or wait for it to finish è in esecuzione uno script: interrompilo o attendi che finisca +@msg this is a compiled script, which cannot be run; use its source text questo è uno script compilato, che non può essere eseguito; usa il suo testo sorgente +@msg the script is not UTF-8 text lo script non è testo UTF-8 +@msg the script is too large to run lo script è troppo grande per essere eseguito +@msg a drag or an undo group is open; finish it before running a script è aperto un trascinamento o un gruppo di annullamento; completalo prima di eseguire uno script +@msg the script left a document that cannot be kept lo script ha lasciato un documento che non può essere conservato +@msg the script's run is no longer available l'esecuzione dello script non è più disponibile +@msg a recovery copy could not be written, so the script was not run non è stato possibile scrivere una copia di recupero, quindi lo script non è stato eseguito +@msg Running a script Esecuzione di uno script @msg Copied the script report Rapporto dello script copiato @msg Corners edits the corners of a path Angoli modifica gli angoli di un tracciato @msg Couldn't copy to the system clipboard: {e} Impossibile copiare negli appunti di sistema: {e} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0..a34df35ab 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4819,6 +4819,27 @@ swatch Bright Violet ビビッドバイオレット @msg the library item is damaged ライブラリの項目が破損しています @msg {_1} font families available {_1} 個のフォントファミリーを使用できます @msg {_1} has no {_2} style {_1} には {_2} スタイルがありません + Run 実行 + Run Script スクリプトを実行 + Stop Script スクリプトを停止 + Show External Script Warning 外部スクリプトの警告を表示 + A Script Is Running スクリプトの実行中 + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. スクリプトは開いているドキュメントを変更でき、実行するコマンドを通じてファイルを書き込むこともできます。信頼できるスクリプトだけを実行してください。 + Do not show this warning again 今後この警告を表示しない + Stop the script and undo what it did? Then quit again. スクリプトを停止して変更を取り消しますか?その後、もう一度終了してください。 + This script has added a great many objects. Continue, or stop it and undo what it did? このスクリプトは非常に多くのオブジェクトを追加しました。続行しますか、それとも停止して変更を取り消しますか? +@msg Choose a .js or .jsx script .js または .jsx スクリプトを選択してください +@msg Ran {name} {name} を実行しました +@msg The script was stopped and its changes were undone スクリプトを停止し、変更を取り消しました +@msg a script is running: stop it or wait for it to finish スクリプトの実行中です: 停止するか、終了するまでお待ちください +@msg this is a compiled script, which cannot be run; use its source text これはコンパイル済みのスクリプトなので実行できません。ソーステキストを使用してください +@msg the script is not UTF-8 text スクリプトは UTF-8 テキストではありません +@msg the script is too large to run スクリプトが大きすぎて実行できません +@msg a drag or an undo group is open; finish it before running a script ドラッグまたは取り消しグループが開いています。スクリプトを実行する前に完了してください +@msg the script left a document that cannot be kept スクリプトが保持できないドキュメントを残しました +@msg the script's run is no longer available スクリプトの実行は利用できなくなりました +@msg a recovery copy could not be written, so the script was not run 復元用のコピーを書き込めなかったため、スクリプトは実行されませんでした +@msg Running a script スクリプトを実行中 @msg Copied the script report スクリプトのレポートをコピーしました @msg Exported {path} {path} を書き出しました @msg no CSS to copy: select objects with CSS of their own コピーする CSS がありません: 独自の CSS を持つオブジェクトを選択してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf5..7e308d05e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5128,6 +5128,27 @@ mark-style Japanese იაპონური Auto Grouping ავტომატური დაჯგუფება Ignore Color ფერის იგნორირება imageTrace Outline კონტური + Run გაშვება + Run Script სკრიპტის გაშვება + Stop Script სკრიპტის შეჩერება + Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება + A Script Is Running სკრიპტი მუშაობს + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. სკრიპტს შეუძლია ღია დოკუმენტების შეცვლა და მის მიერ გაშვებული ბრძანებებით ფაილების ჩაწერა. გაუშვით მხოლოდ სანდო სკრიპტები. + Do not show this warning again აღარ აჩვენო ეს გაფრთხილება + Stop the script and undo what it did? Then quit again. შეჩერდეს სკრიპტი და გაუქმდეს მისი ცვლილებები? შემდეგ ხელახლა გამოდით. + This script has added a great many objects. Continue, or stop it and undo what it did? ამ სკრიპტმა ძალიან ბევრი ობიექტი დაამატა. გაგრძელდეს, თუ შეჩერდეს და გაუქმდეს მისი ცვლილებები? +@msg Choose a .js or .jsx script აირჩიეთ .js ან .jsx სკრიპტი +@msg Ran {name} გაშვებულია: {name} +@msg The script was stopped and its changes were undone სკრიპტი შეჩერდა და მისი ცვლილებები გაუქმდა +@msg a script is running: stop it or wait for it to finish სკრიპტი მუშაობს: შეაჩერეთ ან დაელოდეთ დასრულებას +@msg this is a compiled script, which cannot be run; use its source text ეს კომპილირებული სკრიპტია და მისი გაშვება შეუძლებელია; გამოიყენეთ მისი საწყისი ტექსტი +@msg the script is not UTF-8 text სკრიპტი არ არის UTF-8 ტექსტი +@msg the script is too large to run სკრიპტი გასაშვებად ძალიან დიდია +@msg a drag or an undo group is open; finish it before running a script ღიაა გადათრევა ან გაუქმების ჯგუფი; დაასრულეთ სკრიპტის გაშვებამდე +@msg the script left a document that cannot be kept სკრიპტმა დატოვა დოკუმენტი, რომლის შენახვა შეუძლებელია +@msg the script's run is no longer available სკრიპტის გაშვება აღარ არის ხელმისაწვდომი +@msg a recovery copy could not be written, so the script was not run აღდგენის ასლის ჩაწერა ვერ მოხერხდა, ამიტომ სკრიპტი არ გაეშვა +@msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) ტრაექტორიის წანაცვლება: წანაცვლების შემდეგ მონიშნული ტრაექტორიებიდან არაფერი რჩება (შიგნით წანაცვლება ფიგურებზე დიდია) @msg {path}: not a file {path}: ფაილი არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e8316..3fdbe952a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4746,6 +4746,27 @@ theme Appearance 외관 @msg the library item is damaged 라이브러리 항목이 손상되었습니다 @msg {_1} font families available 사용 가능한 글꼴 패밀리 {_1}개 @msg {_1} has no {_2} style {_1}에는 {_2} 스타일이 없습니다 + Run 실행 + Run Script 스크립트 실행 + Stop Script 스크립트 중지 + Show External Script Warning 외부 스크립트 경고 표시 + A Script Is Running 스크립트 실행 중 + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 스크립트는 열려 있는 문서를 변경하고 실행하는 명령을 통해 파일을 쓸 수 있습니다. 신뢰할 수 있는 스크립트만 실행하세요. + Do not show this warning again 이 경고를 다시 표시하지 않음 + Stop the script and undo what it did? Then quit again. 스크립트를 중지하고 변경 내용을 취소할까요? 그런 다음 다시 종료하세요. + This script has added a great many objects. Continue, or stop it and undo what it did? 이 스크립트가 매우 많은 개체를 추가했습니다. 계속할까요, 아니면 중지하고 변경 내용을 취소할까요? +@msg Choose a .js or .jsx script .js 또는 .jsx 스크립트를 선택하세요 +@msg Ran {name} {name} 실행됨 +@msg The script was stopped and its changes were undone 스크립트를 중지하고 변경 내용을 취소했습니다 +@msg a script is running: stop it or wait for it to finish 스크립트 실행 중: 중지하거나 끝날 때까지 기다리세요 +@msg this is a compiled script, which cannot be run; use its source text 컴파일된 스크립트이므로 실행할 수 없습니다. 소스 텍스트를 사용하세요 +@msg the script is not UTF-8 text 스크립트가 UTF-8 텍스트가 아닙니다 +@msg the script is too large to run 스크립트가 너무 커서 실행할 수 없습니다 +@msg a drag or an undo group is open; finish it before running a script 드래그 또는 실행 취소 그룹이 열려 있습니다. 스크립트를 실행하기 전에 완료하세요 +@msg the script left a document that cannot be kept 스크립트가 유지할 수 없는 문서를 남겼습니다 +@msg the script's run is no longer available 스크립트 실행을 더 이상 사용할 수 없습니다 +@msg a recovery copy could not be written, so the script was not run 복구 사본을 쓸 수 없어 스크립트를 실행하지 않았습니다 +@msg Running a script 스크립트 실행 중 @msg Copied the script report 스크립트 보고서를 복사했습니다 @msg Exported {path} {path}을(를) 내보냈습니다 @msg no CSS to copy: select objects with CSS of their own 복사할 CSS가 없습니다: 자체 CSS가 있는 개체를 선택하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3..7ff35cdfd 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4088,6 +4088,15 @@ imageTrace Outline Contour @msg Combine Slices: the slices have no bounds Slices combineren: de slices hebben geen grenzen @msg Convert to Artboards: selection has no area Converteren naar tekengebieden: de selectie heeft geen oppervlakte @msg Convert to Shape: no rectangles or ellipses selected Converteren naar vorm: geen rechthoeken of ellipsen geselecteerd + Run Uitvoeren + Run Script Script uitvoeren + Stop Script Script stoppen + Show External Script Warning Waarschuwing voor externe scripts tonen + A Script Is Running Er wordt een script uitgevoerd + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Een script kan de geopende documenten wijzigen en via de uitgevoerde opdrachten bestanden schrijven. Voer alleen scripts uit die u vertrouwt. + Do not show this warning again Deze waarschuwing niet meer tonen + Stop the script and undo what it did? Then quit again. Het script stoppen en de wijzigingen ervan ongedaan maken? Sluit daarna opnieuw af. + This script has added a great many objects. Continue, or stop it and undo what it did? Dit script heeft heel veel objecten toegevoegd. Doorgaan, of het stoppen en de wijzigingen ervan ongedaan maken? @msg Copied the script report Scriptrapport gekopieerd @msg Corners edits the corners of a path Hoeken bewerkt de hoeken van een pad @msg Couldn't copy to the system clipboard: {e} Kon niet naar het systeemklembord kopiëren: {e} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679..4fcdf3f7b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4201,6 +4201,27 @@ imageTrace Outline Kontur @msg Combine Slices: the slices have no bounds Połącz plasterki: plasterki nie mają granic @msg Convert to Artboards: selection has no area Konwertuj na obszary kompozycji: zaznaczenie nie ma powierzchni @msg Convert to Shape: no rectangles or ellipses selected Konwertuj na kształt: nie zaznaczono prostokątów ani elips + Run Uruchom + Run Script Uruchom skrypt + Stop Script Zatrzymaj skrypt + Show External Script Warning Pokazuj ostrzeżenie o skryptach zewnętrznych + A Script Is Running Skrypt jest uruchomiony + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrypt może zmieniać otwarte dokumenty i zapisywać pliki za pomocą wykonywanych poleceń. Uruchamiaj tylko skrypty, którym ufasz. + Do not show this warning again Nie pokazuj więcej tego ostrzeżenia + Stop the script and undo what it did? Then quit again. Zatrzymać skrypt i cofnąć jego zmiany? Następnie zakończ program ponownie. + This script has added a great many objects. Continue, or stop it and undo what it did? Ten skrypt dodał bardzo wiele obiektów. Kontynuować, czy zatrzymać go i cofnąć jego zmiany? +@msg Choose a .js or .jsx script Wybierz skrypt .js lub .jsx +@msg Ran {name} Wykonano: {name} +@msg The script was stopped and its changes were undone Skrypt został zatrzymany, a jego zmiany cofnięte +@msg a script is running: stop it or wait for it to finish skrypt jest uruchomiony: zatrzymaj go lub poczekaj na jego zakończenie +@msg this is a compiled script, which cannot be run; use its source text to jest skrypt skompilowany, którego nie można uruchomić; użyj jego tekstu źródłowego +@msg the script is not UTF-8 text skrypt nie jest tekstem UTF-8 +@msg the script is too large to run skrypt jest zbyt duży, aby go uruchomić +@msg a drag or an undo group is open; finish it before running a script otwarte jest przeciąganie lub grupa cofania; zakończ je przed uruchomieniem skryptu +@msg the script left a document that cannot be kept skrypt pozostawił dokument, którego nie można zachować +@msg the script's run is no longer available uruchomienie skryptu nie jest już dostępne +@msg a recovery copy could not be written, so the script was not run nie udało się zapisać kopii odzyskiwania, więc skrypt nie został uruchomiony +@msg Running a script Uruchamianie skryptu @msg Copied the script report Skopiowano raport skryptu @msg Corners edits the corners of a path Narożniki edytuje narożniki ścieżki @msg Couldn't copy to the system clipboard: {e} Nie udało się skopiować do schowka systemowego: {e} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68c..8f7351a7e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5019,6 +5019,27 @@ axis Both Ambos [Normal Paragraph Style] [Estilo de Parágrafo Normal] Joins Junções Axis Eixo + Run Executar + Run Script Executar script + Stop Script Parar script + Show External Script Warning Mostrar aviso de scripts externos + A Script Is Running Um script está em execução + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Um script pode alterar os documentos abertos e gravar arquivos pelos comandos que executa. Execute apenas scripts em que você confia. + Do not show this warning again Não mostrar este aviso novamente + Stop the script and undo what it did? Then quit again. Parar o script e desfazer o que ele fez? Depois, saia novamente. + This script has added a great many objects. Continue, or stop it and undo what it did? Este script adicionou muitíssimos objetos. Continuar, ou pará-lo e desfazer o que ele fez? +@msg Choose a .js or .jsx script Escolha um script .js ou .jsx +@msg Ran {name} {name} executado +@msg The script was stopped and its changes were undone O script foi parado e suas alterações foram desfeitas +@msg a script is running: stop it or wait for it to finish um script está em execução: pare-o ou aguarde até que termine +@msg this is a compiled script, which cannot be run; use its source text este é um script compilado, que não pode ser executado; use seu texto-fonte +@msg the script is not UTF-8 text o script não é texto UTF-8 +@msg the script is too large to run o script é grande demais para ser executado +@msg a drag or an undo group is open; finish it before running a script há um arraste ou um grupo de desfazer aberto; conclua-o antes de executar um script +@msg the script left a document that cannot be kept o script deixou um documento que não pode ser mantido +@msg the script's run is no longer available a execução do script não está mais disponível +@msg a recovery copy could not be written, so the script was not run não foi possível gravar uma cópia de recuperação, portanto o script não foi executado +@msg Running a script Executando um script @msg Copied the script report Relatório do script copiado @msg This shortcut file is in a form VectorCraft can't read. Este arquivo de atalhos está em um formato que o VectorCraft não consegue ler. @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve ser "vertical", "horizontal" ou um ângulo em graus, não `{v}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e83..b5241fa11 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4104,6 +4104,27 @@ axis Both Оба @msg Combine Slices: the slices have no bounds Объединение фрагментов: у фрагментов нет границ @msg Convert to Artboards: selection has no area Преобразование в монтажные области: у выделения нет области @msg Convert to Shape: no rectangles or ellipses selected Преобразование в фигуру: не выделены прямоугольники или эллипсы + Run Выполнить + Run Script Выполнить скрипт + Stop Script Остановить скрипт + Show External Script Warning Показывать предупреждение о внешних скриптах + A Script Is Running Выполняется скрипт + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скрипт может изменять открытые документы и записывать файлы с помощью выполняемых команд. Запускайте только те скрипты, которым доверяете. + Do not show this warning again Больше не показывать это предупреждение + Stop the script and undo what it did? Then quit again. Остановить скрипт и отменить его изменения? Затем выйдите ещё раз. + This script has added a great many objects. Continue, or stop it and undo what it did? Этот скрипт добавил очень много объектов. Продолжить или остановить его и отменить его изменения? +@msg Choose a .js or .jsx script Выберите скрипт .js или .jsx +@msg Ran {name} Выполнено: {name} +@msg The script was stopped and its changes were undone Скрипт остановлен, его изменения отменены +@msg a script is running: stop it or wait for it to finish выполняется скрипт: остановите его или дождитесь завершения +@msg this is a compiled script, which cannot be run; use its source text это скомпилированный скрипт, его нельзя выполнить; используйте его исходный текст +@msg the script is not UTF-8 text скрипт не является текстом UTF-8 +@msg the script is too large to run скрипт слишком велик для выполнения +@msg a drag or an undo group is open; finish it before running a script открыто перетаскивание или группа отмены; завершите их перед запуском скрипта +@msg the script left a document that cannot be kept скрипт оставил документ, который нельзя сохранить +@msg the script's run is no longer available выполнение скрипта больше недоступно +@msg a recovery copy could not be written, so the script was not run не удалось записать копию для восстановления, поэтому скрипт не выполнен +@msg Running a script Выполняется скрипт @msg Copied the script report Отчёт о сценарии скопирован @msg Corners edits the corners of a path «Углы» редактирует углы контура @msg Couldn't copy to the system clipboard: {e} Не удалось скопировать в системный буфер обмена: {e} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d..64e32b2ff 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3978,6 +3978,27 @@ axis Both Båda @msg Combine Slices: the slices have no bounds Kombinera skivor: skivorna har inga gränser @msg Convert to Artboards: selection has no area Konvertera till ritytor: markeringen har ingen area @msg Convert to Shape: no rectangles or ellipses selected Konvertera till form: inga rektanglar eller ellipser är markerade + Run Kör + Run Script Kör skript + Stop Script Stoppa skript + Show External Script Warning Visa varning för externa skript + A Script Is Running Ett skript körs + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ett skript kan ändra de öppna dokumenten och skriva filer genom de kommandon det kör. Kör bara skript du litar på. + Do not show this warning again Visa inte den här varningen igen + Stop the script and undo what it did? Then quit again. Stoppa skriptet och ångra det det gjort? Avsluta sedan igen. + This script has added a great many objects. Continue, or stop it and undo what it did? Det här skriptet har lagt till väldigt många objekt. Fortsätta, eller stoppa det och ångra det det gjort? +@msg Choose a .js or .jsx script Välj ett .js- eller .jsx-skript +@msg Ran {name} Körde {name} +@msg The script was stopped and its changes were undone Skriptet stoppades och dess ändringar ångrades +@msg a script is running: stop it or wait for it to finish ett skript körs: stoppa det eller vänta tills det är klart +@msg this is a compiled script, which cannot be run; use its source text det här är ett kompilerat skript som inte kan köras; använd dess källtext +@msg the script is not UTF-8 text skriptet är inte UTF-8-text +@msg the script is too large to run skriptet är för stort för att köras +@msg a drag or an undo group is open; finish it before running a script en dragning eller ångragrupp är öppen; avsluta den innan du kör ett skript +@msg the script left a document that cannot be kept skriptet lämnade ett dokument som inte kan behållas +@msg the script's run is no longer available skriptets körning är inte längre tillgänglig +@msg a recovery copy could not be written, so the script was not run en återställningskopia kunde inte skrivas, så skriptet kördes inte +@msg Running a script Kör ett skript @msg Copied the script report Kopierade skriptrapporten @msg Corners edits the corners of a path Hörn redigerar hörnen på en bana @msg Couldn't copy to the system clipboard: {e} Kunde inte kopiera till systemets urklipp: {e} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006c..b5720eb53 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3860,6 +3860,27 @@ axis Both Her İkisi @msg Combine Slices: the slices have no bounds Dilimleri Birleştir: dilimlerin sınırları yoktur @msg Convert to Artboards: selection has no area Çalışma Yüzeylerine Dönüştür: seçimin alanı yok @msg Convert to Shape: no rectangles or ellipses selected Şekle Dönüştür: seçili dikdörtgen veya elips yok + Run Çalıştır + Run Script Betiği Çalıştır + Stop Script Betiği Durdur + Show External Script Warning Harici Betik Uyarısını Göster + A Script Is Running Bir Betik Çalışıyor + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Bir betik açık belgeleri değiştirebilir ve çalıştırdığı komutlarla dosya yazabilir. Yalnızca güvendiğiniz betikleri çalıştırın. + Do not show this warning again Bu uyarıyı bir daha gösterme + Stop the script and undo what it did? Then quit again. Betik durdurulsun ve yaptıkları geri alınsın mı? Ardından yeniden çıkın. + This script has added a great many objects. Continue, or stop it and undo what it did? Bu betik çok sayıda nesne ekledi. Devam edilsin mi, yoksa durdurulup yaptıkları geri mi alınsın? +@msg Choose a .js or .jsx script Bir .js veya .jsx betiği seçin +@msg Ran {name} {name} çalıştırıldı +@msg The script was stopped and its changes were undone Betik durduruldu ve değişiklikleri geri alındı +@msg a script is running: stop it or wait for it to finish bir betik çalışıyor: durdurun veya bitmesini bekleyin +@msg this is a compiled script, which cannot be run; use its source text bu derlenmiş bir betik, çalıştırılamaz; kaynak metnini kullanın +@msg the script is not UTF-8 text betik UTF-8 metni değil +@msg the script is too large to run betik çalıştırılamayacak kadar büyük +@msg a drag or an undo group is open; finish it before running a script bir sürükleme veya geri alma grubu açık; betiği çalıştırmadan önce tamamlayın +@msg the script left a document that cannot be kept betik, saklanamayan bir belge bıraktı +@msg the script's run is no longer available betiğin çalıştırması artık kullanılamıyor +@msg a recovery copy could not be written, so the script was not run kurtarma kopyası yazılamadı, bu nedenle betik çalıştırılmadı +@msg Running a script Betik çalıştırılıyor @msg Copied the script report Komut dosyası raporu kopyalandı @msg Corners edits the corners of a path Köşeler bir yolun köşelerini düzenler @msg Couldn't copy to the system clipboard: {e} Sistem panosuna kopyalanamadı: {e} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a5..86680b5eb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4094,6 +4094,27 @@ axis Both Обидві @msg Combine Slices: the slices have no bounds Об’єднання фрагментів: фрагменти не мають кордонів @msg Convert to Artboards: selection has no area Перетворення на монтажні області: виділення не має області @msg Convert to Shape: no rectangles or ellipses selected Перетворення на фігуру: не виділено прямокутники або еліпси + Run Виконати + Run Script Виконати скрипт + Stop Script Зупинити скрипт + Show External Script Warning Показувати попередження про зовнішні скрипти + A Script Is Running Виконується скрипт + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скрипт може змінювати відкриті документи й записувати файли за допомогою команд, які виконує. Запускайте лише ті скрипти, яким довіряєте. + Do not show this warning again Більше не показувати це попередження + Stop the script and undo what it did? Then quit again. Зупинити скрипт і скасувати його зміни? Потім вийдіть ще раз. + This script has added a great many objects. Continue, or stop it and undo what it did? Цей скрипт додав дуже багато об’єктів. Продовжити чи зупинити його та скасувати його зміни? +@msg Choose a .js or .jsx script Виберіть скрипт .js або .jsx +@msg Ran {name} Виконано: {name} +@msg The script was stopped and its changes were undone Скрипт зупинено, його зміни скасовано +@msg a script is running: stop it or wait for it to finish виконується скрипт: зупиніть його або дочекайтеся завершення +@msg this is a compiled script, which cannot be run; use its source text це скомпільований скрипт, його не можна виконати; використайте його вихідний текст +@msg the script is not UTF-8 text скрипт не є текстом UTF-8 +@msg the script is too large to run скрипт завеликий для виконання +@msg a drag or an undo group is open; finish it before running a script відкрито перетягування або групу скасування; завершіть їх перед запуском скрипта +@msg the script left a document that cannot be kept скрипт залишив документ, який не можна зберегти +@msg the script's run is no longer available виконання скрипта більше не доступне +@msg a recovery copy could not be written, so the script was not run не вдалося записати копію для відновлення, тому скрипт не виконано +@msg Running a script Виконується скрипт @msg Copied the script report Звіт про сценарій скопійовано @msg Corners edits the corners of a path «Кути» редагує кути контуру @msg Couldn't copy to the system clipboard: {e} Не вдалося скопіювати до системного буфера обміну: {e} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003..8e95b1edd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4512,6 +4512,27 @@ theme Appearance Giao diện @msg compressed (RLE) bitmaps can't be read yet bitmap nén (RLE) chưa thể đọc được @msg Convert to Artboards: selection has no area Chuyển thành bảng vẽ: vùng chọn không có diện tích @msg Convert to Shape: no rectangles or ellipses selected Chuyển thành hình dạng: chưa chọn hình chữ nhật hay hình elip + Run Chạy + Run Script Chạy tập lệnh + Stop Script Dừng tập lệnh + Show External Script Warning Hiện cảnh báo tập lệnh bên ngoài + A Script Is Running Một tập lệnh đang chạy + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Tập lệnh có thể thay đổi các tài liệu đang mở và ghi tệp thông qua các lệnh mà nó chạy. Chỉ chạy những tập lệnh bạn tin cậy. + Do not show this warning again Không hiện lại cảnh báo này + Stop the script and undo what it did? Then quit again. Dừng tập lệnh và hoàn tác những gì nó đã làm? Sau đó hãy thoát lại. + This script has added a great many objects. Continue, or stop it and undo what it did? Tập lệnh này đã thêm rất nhiều đối tượng. Tiếp tục, hay dừng lại và hoàn tác những gì nó đã làm? +@msg Choose a .js or .jsx script Chọn một tập lệnh .js hoặc .jsx +@msg Ran {name} Đã chạy {name} +@msg The script was stopped and its changes were undone Tập lệnh đã dừng và các thay đổi của nó đã được hoàn tác +@msg a script is running: stop it or wait for it to finish một tập lệnh đang chạy: hãy dừng nó hoặc chờ nó kết thúc +@msg this is a compiled script, which cannot be run; use its source text đây là tập lệnh đã biên dịch, không thể chạy; hãy dùng văn bản nguồn của nó +@msg the script is not UTF-8 text tập lệnh không phải là văn bản UTF-8 +@msg the script is too large to run tập lệnh quá lớn để chạy +@msg a drag or an undo group is open; finish it before running a script đang có thao tác kéo hoặc nhóm hoàn tác; hãy hoàn thành trước khi chạy tập lệnh +@msg the script left a document that cannot be kept tập lệnh để lại một tài liệu không thể giữ +@msg the script's run is no longer available lần chạy tập lệnh không còn khả dụng +@msg a recovery copy could not be written, so the script was not run không thể ghi bản sao khôi phục nên tập lệnh không được chạy +@msg Running a script Đang chạy tập lệnh @msg Copied the script report Đã sao chép báo cáo script @msg Corners edits the corners of a path Corners chỉnh sửa các góc của đường dẫn @msg could not decode image: {0} không thể giải mã ảnh: {0} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d..2bd2f3ade 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4526,6 +4526,27 @@ imageTrace Outline 轮廓 @msg the library item is damaged 库项目已损坏 @msg {_1} font families available 有 {_1} 个可用的字体系列 @msg {_1} has no {_2} style {_1} 没有 {_2} 样式 + Run 运行 + Run Script 运行脚本 + Stop Script 停止脚本 + Show External Script Warning 显示外部脚本警告 + A Script Is Running 脚本正在运行 + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 脚本可以更改打开的文档,并可通过其运行的命令写入文件。请只运行您信任的脚本。 + Do not show this warning again 不再显示此警告 + Stop the script and undo what it did? Then quit again. 要停止脚本并撤销其更改吗?然后请再次退出。 + This script has added a great many objects. Continue, or stop it and undo what it did? 此脚本已添加大量对象。要继续,还是停止并撤销其更改? +@msg Choose a .js or .jsx script 请选择 .js 或 .jsx 脚本 +@msg Ran {name} 已运行 {name} +@msg The script was stopped and its changes were undone 脚本已停止,其更改已撤销 +@msg a script is running: stop it or wait for it to finish 脚本正在运行:请停止它或等待其完成 +@msg this is a compiled script, which cannot be run; use its source text 这是已编译的脚本,无法运行;请使用其源文本 +@msg the script is not UTF-8 text 脚本不是 UTF-8 文本 +@msg the script is too large to run 脚本过大,无法运行 +@msg a drag or an undo group is open; finish it before running a script 拖动或撤销组尚未结束;请在运行脚本前完成 +@msg the script left a document that cannot be kept 脚本留下了无法保留的文档 +@msg the script's run is no longer available 脚本运行已不可用 +@msg a recovery copy could not be written, so the script was not run 无法写入恢复副本,因此未运行脚本 +@msg Running a script 正在运行脚本 @msg Copied the script report 已复制脚本报告 @msg Exported {path} 已导出 {path} @msg no CSS to copy: select objects with CSS of their own 没有可复制的 CSS:请选择带有自身 CSS 的对象 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b..65a0bd587 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2370,6 +2370,27 @@ file-filter Templates 範本 @msg the library item is damaged 資料庫項目已損毀 @msg {_1} font families available 有 {_1} 個可用的字體家族 @msg {_1} has no {_2} style {_1} 沒有 {_2} 樣式 + Run 執行 + Run Script 執行指令碼 + Stop Script 停止指令碼 + Show External Script Warning 顯示外部指令碼警告 + A Script Is Running 指令碼正在執行 + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 指令碼可以變更開啟的文件,並可透過其執行的命令寫入檔案。請只執行您信任的指令碼。 + Do not show this warning again 不再顯示此警告 + Stop the script and undo what it did? Then quit again. 要停止指令碼並還原其變更嗎?然後請再次結束。 + This script has added a great many objects. Continue, or stop it and undo what it did? 此指令碼已新增大量物件。要繼續,還是停止並還原其變更? +@msg Choose a .js or .jsx script 請選擇 .js 或 .jsx 指令碼 +@msg Ran {name} 已執行 {name} +@msg The script was stopped and its changes were undone 指令碼已停止,其變更已還原 +@msg a script is running: stop it or wait for it to finish 指令碼正在執行:請停止它或等待其完成 +@msg this is a compiled script, which cannot be run; use its source text 這是已編譯的指令碼,無法執行;請使用其原始文字 +@msg the script is not UTF-8 text 指令碼不是 UTF-8 文字 +@msg the script is too large to run 指令碼過大,無法執行 +@msg a drag or an undo group is open; finish it before running a script 拖曳或還原群組尚未結束;請在執行指令碼前完成 +@msg the script left a document that cannot be kept 指令碼留下了無法保留的文件 +@msg the script's run is no longer available 指令碼執行已無法使用 +@msg a recovery copy could not be written, so the script was not run 無法寫入復原副本,因此未執行指令碼 +@msg Running a script 正在執行指令碼 @msg Copied the script report 已複製指令碼報告 @msg Exported {path} 已匯出 {path} @msg no CSS to copy: select objects with CSS of their own 沒有可複製的 CSS:請選取有自己 CSS 的物件 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 03acb7597..d6c91d3d1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -65,6 +65,7 @@ pub mod print; pub mod recovery; pub mod render_worker; pub mod screen_color; +pub mod script_runs; mod scrub; pub mod shortcut_editor; pub mod shortcuts; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 9ec98faae..92186e9f3 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -969,6 +969,7 @@ fn run_ui_part13(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(crate::trace_runs::cancel_all(app)), "imageTrace.hideRuns" => Ok(crate::trace_runs::hide_all(app)), "ui.checkScript" => crate::dialogs::script_check::command(app, p), + "ui.runScript" => crate::dialogs::script_run::command(app, p), "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), "ui.perspectivePresetsDialog" => { crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index c51f36e7d..ebea83e90 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -62,7 +62,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Export Selection…", "file.exportSelection"), Sep, c("Package…", "ui.packageDialog"), - sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]), + sub("Scripts", vec![c("Check Script…", "ui.checkScript"), c("Other Script…", "ui.runScript")]), Sep, c("Document Setup…", "file.documentSetup"), sub( diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 3b314f833..fb78fc207 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1520,6 +1520,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} close the progress window of the Image Traces running in the background; they go on, shown in the status bar → {hidden: n}", ), + ( + "ui.runScript", + "Other Script…", + "Cmd+F12", + "{path? | source?, name?} run a .js or .jsx file (script.run), after the warning unless Preferences › General › Show External Script Warning is off (dialog `scriptRun`: `name`, `script`, `dontShow`); without a file pick one (the web opens the browser's file picker)", + ), ( "ui.checkScript", "Check Script…", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 28a4d9d11..4122c1941 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -72,6 +72,7 @@ const DOCUMENT_FREE: &[&str] = &[ "file.newFromTemplate", "ui.installPlugin", "ui.checkScript", + "ui.runScript", "window.swatchLibrary.other", "window.graphicStyleLibrary.other", "ui.libraryPanel.locate", diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs new file mode 100644 index 000000000..d88c0bf1b --- /dev/null +++ b/crates/ui-egui/src/script_runs.rs @@ -0,0 +1,44 @@ +//! A running script, advanced one slice per frame: `script.run` starts it and [`poll`] takes it to +//! its end, while the app goes on drawing the documents as they were before the run. The status bar +//! shows it with a Stop button; a run that has added a great deal waits for the person's answer. + +use serde_json::json; + +use crate::VectorcraftApp; +use crate::dialogs::script_run; +use crate::state::Dialog; + +/// Advances the running script by one slice, or asks what its growth needs asked. +pub fn poll(app: &mut VectorcraftApp) { + script_run::drain(app); + if !app.session.script_running() { + return; + } + if app.session.script_waiting() { + if app.ui.dialog.is_none() { + app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({}))); + } + return; + } + let name = app.session.script_name().unwrap_or_default(); + let Some(result) = app.session.script_pump() else { return }; + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::GROWTH_KIND) { + app.ui.dialog = None; + } + match result { + Ok(_) => app.status(format!("Ran {name}")), + Err(e) => failed(app, e.to_string()), + } + app.sync_views(); +} + +/// Shows why a run ended without its changes. A stopped run that wrote no file says only that it +/// was stopped; any other failure shows its error, which names the files left written. +pub(crate) fn failed(app: &mut VectorcraftApp, error: String) { + let stopped = app.session.script_report().is_some_and(|r| r["kind"] == "stopped" && r["written"].as_array().is_none_or(Vec::is_empty)); + if stopped { + app.status("The script was stopped and its changes were undone"); + } else { + app.status(error); + } +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 47374c7e6..901c0a340 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -107,6 +107,11 @@ pub struct Services { /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the /// canvas). pub place_inbox: Option, + /// Web: start an async pick of a script to run (it arrives via `script_inbox`; a canceled pick + /// delivers nothing). + pub script_async: Option>, + /// Scripts the web's picker delivered for File › Scripts › Other Script…. + pub script_inbox: Option, /// The system clipboard with all its formats (desktop): Copy offers text, SVG, PDF and PNG, /// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through /// egui and `clipboard_read`). diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 2593115f6..261a25054 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -39,6 +39,7 @@ mod saveext; mod screenmode; mod screenshots; mod script_check; +mod script_run; mod scrub; mod selectall; mod selectescape; diff --git a/crates/ui-egui/src/tests/script_check.rs b/crates/ui-egui/src/tests/script_check.rs index ccaa6c7a8..0ff703eb3 100644 --- a/crates/ui-egui/src/tests/script_check.rs +++ b/crates/ui-egui/src/tests/script_check.rs @@ -46,7 +46,7 @@ fn file_scripts_holds_check_script_above_other_script() { let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap(); let scripts = file.iter().find_map(|i| if let menus::Item::Sub("Scripts", items) = i { Some(items) } else { None }).unwrap(); assert!( - matches!(scripts.as_slice(), [menus::Item::Cmd("Check Script…", "ui.checkScript", _), menus::Item::Todo("Other Script…", _)]), + matches!(scripts.as_slice(), [menus::Item::Cmd("Check Script…", "ui.checkScript", _), menus::Item::Cmd("Other Script…", "ui.runScript", _)]), "{scripts:?}" ); } diff --git a/crates/ui-egui/src/tests/script_run.rs b/crates/ui-egui/src/tests/script_run.rs new file mode 100644 index 000000000..08ac8e191 --- /dev/null +++ b/crates/ui-egui/src/tests/script_run.rs @@ -0,0 +1,283 @@ +//! File › Scripts › Other Script… (`ui.runScript`): the chooser, the warning and its preference, +//! a run advanced frame by frame, Stop, and the questions a run and quitting ask. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, dialogs, menus, script_runs}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app +} + +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vc-script-run-ui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +fn finish(app: &mut VectorcraftApp) { + for _ in 0..10_000 { + if !app.session.script_running() { + return; + } + script_runs::poll(app); + } + panic!("the script did not end"); +} + +const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10}); vc.run('shape.rectangle', {x:20,y:0,width:10,height:10});"; + +fn confirm(app: &mut VectorcraftApp) -> Result { + dialogs::confirm(app) +} + +#[test] +fn the_warning_comes_before_the_run_and_cancel_runs_nothing() { + let mut app = app(); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": DRAW, "name": "draw.js"})).unwrap().unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["name"].as_str()), (dialogs::script_run::KIND, Some("draw.js"))); + assert!(!app.session.script_running()); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert!(!app.session.script_running()); + finish(&mut app); + assert_eq!(objects(&app), 0); +} + +#[test] +fn a_run_goes_frame_by_frame_and_is_one_undo_step() { + let mut app = app(); + let dir = folder("draw"); + let path = dir.join("Dessin (été) #1.JSX"); + std::fs::write(&path, DRAW).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"path": path.to_string_lossy()})).unwrap().unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["name"], "Dessin (été) #1.JSX"); + confirm(&mut app).unwrap(); + assert!(app.session.script_running()); + assert_eq!(objects(&app), 0, "nothing shows until the run commits"); + finish(&mut app); + assert_eq!(objects(&app), 2); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(app.ui.status, "Ran Dessin (été) #1.JSX"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(objects(&app), 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn do_not_show_again_is_remembered_and_the_script_still_runs() { + let mut app = app(); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": DRAW})).unwrap().unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("dontShow".into(), json!(true)); + confirm(&mut app).unwrap(); + finish(&mut app); + assert!(!app.session.prefs.show_external_script_warning); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": DRAW})).unwrap().unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(app.session.script_running()); + finish(&mut app); + assert_eq!(objects(&app), 4); +} + +#[test] +fn only_js_and_jsx_files_are_taken() { + let mut app = app(); + for name in ["a.txt", "a.jsxbin", "a"] { + let e = menus::run_ui_command(&mut app, "ui.runScript", &json!({ "path": name })).unwrap().unwrap_err(); + assert!(e.contains(".js or .jsx"), "{name}: {e}"); + } + assert!(app.ui.dialog.is_none()); +} + +#[test] +fn stop_puts_the_document_back_and_says_so() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + let source = format!("{DRAW} while (true) {{}}"); + menus::run_ui_command(&mut app, "ui.runScript", &json!({ "source": source })).unwrap().unwrap(); + script_runs::poll(&mut app); + assert!(app.session.script_running()); + assert!(app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap_err().contains("a script is running")); + app.run("script.stop", json!({})).unwrap(); + finish(&mut app); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn a_script_error_goes_to_the_status_bar() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": "throw new Error('nope');", "name": "x.js"})).unwrap().unwrap(); + finish(&mut app); + assert_eq!(app.ui.status, "x.js: line 1, column 1: Error: nope (thrown)"); +} + +/// An app whose script picker is the web's: picks are counted and files arrive through its inbox. +fn app_with_picker() -> (VectorcraftApp, dialogs::script_run::ScriptInbox, std::rc::Rc>) { + let mut app = app(); + let inbox = dialogs::script_run::ScriptInbox::default(); + let picks = std::rc::Rc::new(std::cell::Cell::new(0)); + let counted = picks.clone(); + app.services.script_async = Some(Box::new(move || counted.set(counted.get() + 1))); + app.services.script_inbox = Some(inbox.clone()); + (app, inbox, picks) +} + +#[test] +fn a_canceled_web_pick_does_not_take_a_dropped_script() { + let (mut app, _inbox, picks) = app_with_picker(); + menus::run_ui_command(&mut app, "ui.runScript", &json!({})).unwrap().unwrap(); + assert_eq!(picks.get(), 1); + // A pick that was canceled delivers nothing, and the next file that opens is an ordinary open. + script_runs::poll(&mut app); + let _ = crate::io::open_bytes(&mut app, "dropped.js", DRAW.as_bytes(), None); + assert!(app.ui.dialog.as_ref().is_none_or(|d| d.kind != dialogs::script_run::KIND)); + assert!(!app.session.script_running()); + assert_eq!(objects(&app), 0); +} + +#[test] +fn a_web_pick_goes_to_the_warning_and_waits_for_an_open_dialog() { + let (mut app, inbox, _picks) = app_with_picker(); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, json!({}))); + inbox.lock().unwrap().push(("picked.js".to_string(), Some(DRAW.as_bytes().to_vec()))); + script_runs::poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, dialogs::script_run::CLOSE_KIND); + assert_eq!(inbox.lock().unwrap().len(), 1, "the file waits"); + app.ui.dialog = None; + script_runs::poll(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["name"].as_str()), (dialogs::script_run::KIND, Some("picked.js"))); + confirm(&mut app).unwrap(); + finish(&mut app); + assert_eq!(objects(&app), 2); +} + +#[test] +fn a_web_pick_that_is_not_text_is_reported() { + let (mut app, inbox, _picks) = app_with_picker(); + inbox.lock().unwrap().push(("bad.js".to_string(), Some(vec![0xff, 0xfe]))); + script_runs::poll(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, "the script is not UTF-8 text"); +} + +#[test] +fn a_web_pick_too_large_to_read_is_reported() { + let (mut app, inbox, _picks) = app_with_picker(); + inbox.lock().unwrap().push(("big.js".to_string(), None)); + script_runs::poll(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, "the script is too large to run"); +} + +/// A run that never ends, with the growth question up. +fn at_the_growth_question(app: &mut VectorcraftApp) { + app.session.prefs.show_external_script_warning = false; + let source = format!("{DRAW} while (true) {{}}"); + menus::run_ui_command(app, "ui.runScript", &json!({ "source": source })).unwrap().unwrap(); + script_runs::poll(app); + assert!(app.session.script_running()); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::GROWTH_KIND, json!({}))); +} + +#[test] +fn the_growth_questions_stop_button_stops_the_run() { + let mut app = app(); + at_the_growth_question(&mut app); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + finish(&mut app); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn escape_on_the_growth_question_stops_the_run() { + let mut app = app(); + at_the_growth_question(&mut app); + let key = egui::Key::Escape; + let events = vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]; + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + output.textures_delta.clear(); + assert!(app.ui.dialog.is_none()); + finish(&mut app); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn a_stopped_run_closes_its_growth_question() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": "while (true) {}"})).unwrap().unwrap(); + script_runs::poll(&mut app); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::GROWTH_KIND, json!({}))); + app.run("script.stop", json!({})).unwrap(); + finish(&mut app); + assert!(app.ui.dialog.is_none()); +} + +#[test] +fn finished_background_work_waits_for_the_run_to_end() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + app.opening.activate.push(("no such document".to_string(), vectorcraft_engine::file_access::ScopeHandle::capture())); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": "while (true) {}"})).unwrap().unwrap(); + app.poll_finished_work(); + assert_eq!(app.opening.activate.len(), 1); + app.run("script.stop", json!({})).unwrap(); + finish(&mut app); + app.poll_finished_work(); + assert!(app.opening.activate.is_empty()); +} + +#[test] +fn quitting_asks_to_stop_and_stop_undoes_the_run() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + let source = format!("{DRAW} while (true) {{}}"); + menus::run_ui_command(&mut app, "ui.runScript", &json!({ "source": source })).unwrap().unwrap(); + script_runs::poll(&mut app); + assert!(app.session.script_running()); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, json!({}))); + confirm(&mut app).unwrap(); + assert!(!app.session.script_running()); + assert_eq!(objects(&app), 0); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn a_stop_names_the_files_the_run_left_written() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + let dir = folder("written"); + let path = dir.join("out.svg"); + let p = path.to_string_lossy().into_owned(); + let source = format!("vc.run('document.export', {{path: {p:?}, format: 'svg'}}); while (true) {{}}"); + menus::run_ui_command(&mut app, "ui.runScript", &json!({ "source": source })).unwrap().unwrap(); + script_runs::poll(&mut app); + assert!(path.exists()); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, json!({}))); + confirm(&mut app).unwrap(); + assert!(app.ui.status.contains(&p), "{}", app.ui.status); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_new_app_shows_the_warning_by_default() { + assert!(Session::new().prefs.show_external_script_warning); +} diff --git a/docs/gaps.md b/docs/gaps.md index 0939dbf3d..5372fb46d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -690,8 +690,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G6 Automation and scripting -- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript, File › Scripts; - #1183 is open). Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and +- **Missing:** the document object model for scripts, a script console, the Scripts folder and script + directives (#1183 is open). File › Scripts › Other Script… and `script.run` run JavaScript files over the + command registry. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and export, and full agent control over MCP are done. - **Estimate:** 4–8 h. - **Done (2026-10-16):** the MCP server hears the client during a long call: `notifications/cancelled` stops a queued or running export (PDF pages, flattening, baking, drawing, artboards) without writing anything or changing the document, `ping` and the list methods answer at once, any other request that would wait behind a call running for two seconds or more is refused as busy (`-32003`, naming the call), and `_meta.progressToken` gets page progress (#2165, #2171, #2172). Reading the document during an export is not served (refused as busy); estimate unchanged. diff --git a/docs/mcp.md b/docs/mcp.md index 81dd91344..127e3ccdc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1452,6 +1452,74 @@ the included files too. vectorcraft-cli run --cmd script.check --params '{"path": "Tidy Layers.jsx"}' ``` +`script.run {path? | source?, name?, args?, answers?, maxFuel?}` runs a JavaScript file against the open documents. +File › Scripts › Other Script… (Cmd+F12) picks a `.js` or `.jsx` file and runs it the same way; the help page +https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html +describes that menu item. Pass exactly one of `path` and `source`. A `path` must end in `.js` or `.jsx`, in any +case. A `.jsxbin` file or text that starts with `@JSXBIN@` is compiled and ends with an error, as does text that is +not UTF-8. The language is ES5.1. The script reaches the app through one global object: + +- `vc.run(id, params)` runs a command and returns its result. A command that fails throws an error the script can + catch. The documents and the session state the command changed, preferences included, go back, and any file it + wrote stays written and is named in the report. A result too large to cross throws a `RangeError`, and the + command's changes go back the same way. +- `vc.document` is a snapshot of the active document, or `null` when none is open. A snapshot too large to cross + throws a `RangeError`. +- `vc.commands` lists the commands a script may run. +- `vc.log(value)` adds a value to the report. +- `vc.args` is the `args` object. +- `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature. + +The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A +run that changes no document adds no undo step. The journal records each command the run finished, as it records +commands run one at a time. A preference the run changed takes the run's value, and a preference changed in the app +while the run went on keeps that change. On an uncaught error, a limit or `script.stop`, every open document and the +session go back to their state before the run. A script can write files through the commands it runs, such as +export commands. A file written before the run failed stays on disk, and the error names it. A run from the menu +shows a warning first, unless you ticked Do not show this warning again or set the preference +`showExternalScriptWarning` to false. + +While a script runs, every other command that changes anything ends with the error `a script is running: stop it or +wait for it to finish`. MCP reports that error as JSON-RPC error `-32003`. `script.stop {}` stops the run and +returns `{stopped}`. `notifications/cancelled` stops a run only while the `tools/call` that started it is waiting +for it, as in a headless server; use `script.stop` for a run the app started. A script cannot run commands that +change anything outside the documents: `script.*` except `script.check`, `plugin.*`, `prefs.*`, `actionSet.*`, +`shortcutSet.*`, `help.*`, `print.*`, `library.*`, `importMatches.*`, `edit.undoGroup.*`, `file.recovery.*`, +`stroke.widthProfile.*`, saving, importing or deleting a preset, `text.addFontFiles`, `text.findFontFiles`, +`text.rescanFonts`, `links.findFiles`, `file.place.queue`, `color.loadProfile`, `color.removeProfile`, `file.print`, +`view.proofSetup`, `view.proofColors`, `view.overprintPreview` and `view.separationsPreview`. Calling one throws an +error. With Data Recovery off for complex documents, a run in the web build does not start while a complex document +has unsaved changes. + +VectorCraft sets these limits itself: + +| Limit | Value | +|-------|-------| +| Source text | 8 MiB | +| Heap | 256 MiB (128 MiB on the web) | +| Host calls | 2,000,000 | +| `maxFuel`, instructions in all | 1 to 100,000,000,000; 1,000,000,000 by default | +| Instructions without a pause | 200,000,000 | +| Nesting depth | 64 levels | +| A value crossing to or from a command | 16 MiB and 1,048,576 entries | +| `args` as JSON | 1 MiB | +| Log | 10,000 entries and 4 MiB | +| Growth before a person is asked | 100,000 objects and 512 MiB of new document data | + +In the app, a script past the growth limit stops at a question to continue or to stop and undo. Stop, Escape and +`ui.dialog.cancel` all stop the run. A run with nobody to ask, such as a call over MCP or the command line, ends +with an error at that limit. The headless reply is `{ok: true, name, changed, written, log, fuelUsed, hostCalls}`. +In the app the reply is `{running: true, name}`, and the report appears later as `scriptReport` in `ui.inspect`. +`answers` takes only an empty object, since scripts have no prompts yet. + +```json +{"name":"run_command","arguments":{"command":"script.run","params":{"source":"vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10});"}}} +``` + +```sh +vectorcraft-cli run --cmd script.run --params '{"path": "Tidy Layers.js", "args": {"margin": 12}}' +``` + ## Transparency and opacity masks Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage diff --git a/docs/roadmap.md b/docs/roadmap.md index 19369b9fa..bde1bd308 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 | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | -| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | +| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files; the script document model and console and batch work remain | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c37d5234c..a238d5b96 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -704,6 +704,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: a type object holds one text variable. `variable.bind` and Make Text Dynamic refuse a second one and name the text variable the object has; a visibility variable can still share the object, and a document saved with two text variables on one object opens with both. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. + - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). From 9560b7cfe99977fd60e439be6551a3da0fc2bf5e Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 07:46:05 +0500 Subject: [PATCH 16/48] Spelling in the canvas context menu: suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (part of #4936) --- crates/ui-egui/src/canvas/context.rs | 3 + crates/ui-egui/src/canvas/tool_overlays.rs | 73 ++++++++++-- crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/menus/context.rs | 36 +++++- crates/ui-egui/src/menus/entries.rs | 7 +- crates/ui-egui/src/menus/labels.rs | 9 ++ crates/ui-egui/src/state.rs | 20 ++++ crates/ui-egui/src/tests/contextmenu.rs | 122 +++++++++++++++++++++ docs/gaps.md | 5 +- docs/mcp.md | 4 + docs/roadmap.md | 9 +- docs/target-app-parity.md | 19 ++-- docs/type-parity.md | 5 +- 32 files changed, 346 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index 5af09ab3b..c5bbe9088 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -20,6 +20,8 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: .active() .filter(|_| app.session.tool_id() == "intertwine") .and_then(|st| vectorcraft_tools::intertwine::menu_target(&st.doc, &st.selection.objects, p).map(|(g, m)| (st.uid, g, p, m))); + // Auto Spell Check: the underlined word right-clicked, for its suggestions. + app.ui.context_spelling = if app.ui.view.auto_spell_check { super::tool_overlays::misspelled_word_at(app, p) } else { None }; let on_artboards = app.session.tool_id() == "artboard"; if on_artboards { crate::panels::artboards::right_click(app, p); @@ -39,6 +41,7 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: app.ui.context_locked = None; app.ui.context_intertwine = None; app.ui.context_artboards = None; + app.ui.context_spelling = None; } if let Some((id, p)) = clicked { crate::menus::invoke(app, &id, p); diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index b90af09a4..5e23522f7 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -144,13 +144,7 @@ pub(super) fn spelling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: } walk(&st.doc.layers, &mut |n| { let NodeKind::Text(tx) = &n.kind else { return }; - // An effect on the type or on one of its fills or strokes, shown (the Appearance panel's - // eye): the helpx page's "Auto spell check isn't available for text with effects applied". - let shown = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); - if shown(&n.appearance.effects) - || n.appearance.items.iter().any(|i| shown(i.effects())) - || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) - { + if !auto_spell_checks(n) || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) { return; } let lines = match last.remove(&n.id) { @@ -164,6 +158,71 @@ pub(super) fn spelling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: }); } +/// Whether Auto Spell Check marks `n`'s words: not with an effect shown (the Appearance panel's +/// eye) on the type or on one of its fills or strokes, as the helpx page says "Auto spell check +/// isn't available for text with effects applied". +fn auto_spell_checks(n: &Node) -> bool { + let shown = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); + !shown(&n.appearance.effects) && !n.appearance.items.iter().any(|i| shown(i.effects())) +} + +/// The word with Auto Spell Check's red line at `p` (document space), on top of the others: in +/// the topmost visible, unlocked type object whose unknown word's glyphs `p` is on, with what its +/// language's dictionary suggests instead. For the context menu's suggestions, Ignore All and Add +/// to Dictionary ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)). +pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option { + let st = app.session.active()?; + let mut hit = None; + fn walk(nodes: &[std::sync::Arc], f: &mut impl FnMut(&Node)) { + for n in nodes.iter().filter(|n| n.visible && !n.locked) { + f(n); + if let Some(children) = n.children() { + walk(children, f); + } + } + } + // Paint order: a later hit is on top. + walk(&st.doc.layers, &mut |n| { + let NodeKind::Text(tx) = &n.kind else { return }; + if !auto_spell_checks(n) || n.geometric_bounds().is_none_or(|b| !b.contains(p)) { + return; + } + let (words, _) = vectorcraft_engine::cmd::spelling::misspellings(tx, &st.spelling_ignored, MAX_UNDERLINED); + if words.is_empty() { + return; + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); + for w in words { + let on = vectorcraft_text::selection_quads(&lay, w.range.start, w.range.end).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)); + if on { + hit = Some((n.id, w)); + } + } + }); + let (id, w) = hit?; + let Some(NodeKind::Text(tx)) = st.doc.node(id).map(|n| &n.kind) else { return None }; + let word = tx.plain_text().get(w.range.clone())?.to_string(); + let uid = st.uid; + let suggestions = app + .session + .execute("text.spelling.suggest", &serde_json::json!({ "word": word, "lang": w.lang.tag() })) + .ok() + .and_then(|r| r.get("suggestions").and_then(|s| s.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect())) + .unwrap_or_default(); + Some(crate::state::ContextSpelling { uid, id, start: w.range.start, end: w.range.end, word, suggestions }) +} + +/// Whether `p` is inside the convex quad `q` (its corners in order, either way round) or on its +/// edge. +fn in_quad(q: [Point; 4], p: Point) -> bool { + let sides = (0..4).map(|i| { + let (a, b) = (q[i], q[(i + 1) % 4]); + (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) + }); + let sides: Vec = sides.collect(); + sides.iter().all(|s| *s >= 0.0) || sides.iter().all(|s| *s <= 0.0) +} + /// The most words underlined in one text object: past it the rest of the object isn't checked, /// so a long story of unknown words costs one check's worth. const MAX_UNDERLINED: usize = 1_000; diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index bc984eec5..6583bfde6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6648,3 +6648,6 @@ breakText Glyphs Glifs Align to Pixel Grid Alinea a la quadrícula de píxels @msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels @msg side is left or right, not `{s}` side ha de ser left o right, no `{s}` + Ignore All Ignora-ho tot + Add to Dictionary Afegeix al diccionari + No Suggestions No hi ha suggeriments diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fc357028f..173a09ea0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6667,3 +6667,6 @@ breakText Glyphs Glyfy Align to Pixel Grid Zarovnat na mřížku obrazových bodů @msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty @msg side is left or right, not `{s}` side je left nebo right, ne `{s}` + Ignore All Ignorovat vše + Add to Dictionary Přidat do slovníku + No Suggestions Žádné návrhy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b66afbe45..af42a42d7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6254,3 +6254,6 @@ breakText Glyphs Glyphen Align to Pixel Grid Am Pixelraster ausrichten @msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden @msg side is left or right, not `{s}` side ist left oder right, nicht `{s}` + Ignore All Alle ignorieren + Add to Dictionary Zum Wörterbuch hinzufügen + No Suggestions Keine Vorschläge diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 632a6feec..448354967 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6619,3 +6619,6 @@ breakText Glyphs Γλύφοι Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων @msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων @msg side is left or right, not `{s}` το side είναι left ή right, όχι `{s}` + Ignore All Παράβλεψη όλων + Add to Dictionary Προσθήκη στο λεξικό + No Suggestions Καμία πρόταση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3369528a1..02161d827 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6610,3 +6610,6 @@ breakText Glyphs Glifos Align to Pixel Grid Alinear con cuadrícula de píxeles @msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles @msg side is left or right, not `{s}` side es left o right, no `{s}` + Ignore All Omitir todo + Add to Dictionary Añadir al diccionario + No Suggestions No hay sugerencias diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ba0cff27..9eb0230ca 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6702,3 +6702,6 @@ breakText Glyphs Merkit Align to Pixel Grid Tasaa pikseliruudukkoon @msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon @msg side is left or right, not `{s}` side on left tai right, ei `{s}` + Ignore All Ohita kaikki + Add to Dictionary Lisää sanastoon + No Suggestions Ei ehdotuksia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6a7817aea..d02ff3bde 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6662,3 +6662,6 @@ breakText Glyphs Glyphes Align to Pixel Grid Aligner sur la grille de pixels @msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels @msg side is left or right, not `{s}` side vaut left ou right, pas `{s}` + Ignore All Tout ignorer + Add to Dictionary Ajouter au dictionnaire + No Suggestions Aucune suggestion diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1d5571d2d..244549346 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8109,3 +8109,6 @@ theme Appearance अपीयरेंस @msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` इंपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है @msg an importer always gets an import's budget: leave `budget` out इंपोर्टर को हमेशा इंपोर्ट का बजट मिलता है: `budget` छोड़ दें @msg side is left or right, not `{s}` side left या right होना चाहिए, `{s}` नहीं + Ignore All सभी को अनदेखा करें + Add to Dictionary शब्दकोश में जोड़ें + No Suggestions कोई सुझाव नहीं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 92d7822e4..9e65a755a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6651,3 +6651,6 @@ breakText Glyphs Glifák Align to Pixel Grid Igazítás a pixelrácshoz @msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz @msg side is left or right, not `{s}` a side értéke left vagy right lehet, nem `{s}` + Ignore All Az összes kihagyása + Add to Dictionary Hozzáadás a szótárhoz + No Suggestions Nincs javaslat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f710a566..904ca4c9a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6288,3 +6288,6 @@ breakText Glyphs Glifi Align to Pixel Grid Allinea alla griglia pixel @msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel @msg side is left or right, not `{s}` side è left o right, non `{s}` + Ignore All Ignora tutto + Add to Dictionary Aggiungi al dizionario + No Suggestions Nessun suggerimento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6403b1e8a..0c5383e91 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6353,3 +6353,6 @@ breakText Glyphs 字形 Align to Pixel Grid ピクセルグリッドに整合 @msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです @msg side is left or right, not `{s}` side は left または right です(`{s}` ではありません) + Ignore All すべて無視 + Add to Dictionary 辞書に追加 + No Suggestions 候補なし diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0b6755253..d37359079 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6728,3 +6728,6 @@ font-script Japanese იაპონური Align to Pixel Grid პიქსელურ ბადეზე გასწორება @msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი @msg side is left or right, not `{s}` side არის left ან right და არა `{s}` + Ignore All ყველას იგნორირება + Add to Dictionary ლექსიკონში დამატება + No Suggestions შემოთავაზებები არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 64844bf7c..18121f268 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6249,3 +6249,6 @@ breakText Glyphs 글리프 Align to Pixel Grid 픽셀 격자에 정렬 @msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다 @msg side is left or right, not `{s}` side는 left 또는 right입니다(`{s}` 아님) + Ignore All 모두 무시 + Add to Dictionary 사전에 추가 + No Suggestions 제안 없음 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd33ebd05..ab9411415 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6523,3 +6523,6 @@ breakText Glyphs Glify Align to Pixel Grid Wyrównaj do siatki pikseli @msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone @msg side is left or right, not `{s}` side to left lub right, a nie `{s}` + Ignore All Ignoruj wszystko + Add to Dictionary Dodaj do słownika + No Suggestions Brak sugestii diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a80de1e0c..c28eb8798 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6740,3 +6740,6 @@ swatch Yellow Green Verde-amarelado Align to Pixel Grid Alinhar à Grade de Pixels @msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels @msg side is left or right, not `{s}` side é left ou right, não `{s}` + Ignore All Ignorar Tudo + Add to Dictionary Adicionar ao Dicionário + No Suggestions Nenhuma Sugestão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2ddc692a3..de217605b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6756,3 +6756,6 @@ shape Open Открытый Align to Pixel Grid Выровнять по пиксельной сетке @msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры @msg side is left or right, not `{s}` side — это left или right, а не `{s}` + Ignore All Пропустить все + Add to Dictionary Добавить в словарь + No Suggestions Нет вариантов diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7a0e3f39b..b712cc51b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8152,3 +8152,6 @@ digits Hindi Hindi Align to Pixel Grid Justera mot pixelrutnät @msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet @msg side is left or right, not `{s}` side är left eller right, inte `{s}` + Ignore All Ignorera alla + Add to Dictionary Lägg till i ordlistan + No Suggestions Inga förslag diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 67b958ebd..7c80c3568 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6623,3 +6623,6 @@ breakText Glyphs Glifler Align to Pixel Grid Piksel Izgarasına Hizala @msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir @msg side is left or right, not `{s}` side left veya right olmalıdır, `{s}` değil + Ignore All Tümünü Yoksay + Add to Dictionary Sözlüğe Ekle + No Suggestions Öneri Yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67050653e..c835a9998 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6268,3 +6268,6 @@ breakText Glyphs Гліфи Align to Pixel Grid Вирівняти за піксельною сіткою @msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури @msg side is left or right, not `{s}` side — це left або right, а не `{s}` + Ignore All Пропустити все + Add to Dictionary Додати до словника + No Suggestions Немає варіантів diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7c0279792..92392744a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6269,3 +6269,6 @@ breakText Glyphs Glyph Align to Pixel Grid Căn theo lưới pixel @msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được @msg side is left or right, not `{s}` side là left hoặc right, không phải `{s}` + Ignore All Bỏ qua tất cả + Add to Dictionary Thêm vào từ điển + No Suggestions Không có gợi ý diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index bc191593a..387cd099c 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -14,7 +14,8 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind)); let several = roots.len() >= 2; - let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]; + let mut v = spelling_items(app); + v.extend([c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]); if st.isolation.is_some() { v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]); } @@ -108,6 +109,39 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { prune(app, v) } +/// A right-click on a word Auto Spell Check underlined ([`crate::state::UiState::context_spelling`]): +/// the words its dictionary suggests, each replacing it (`text.editRange`, one undo step; +/// `spelling_item_label` shows the word), or No Suggestions; then Ignore All +/// (`text.spelling.ignoreAll`) and Add to Dictionary (`text.spelling.addWord`), as the public +/// [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html) +/// page lists them. Nothing for another right-click, or after the document or the word changed. +fn spelling_items(app: &VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let Some(w) = app.ui.context_spelling.as_ref().filter(|w| w.uid == st.uid) else { return vec![] }; + let still = match st.doc.node(w.id).map(|n| &n.kind) { + Some(vectorcraft_doc::NodeKind::Text(t)) => t.plain_text().get(w.start..w.end) == Some(w.word.as_str()), + _ => false, + }; + if !still { + return vec![]; + } + let mut v: Vec = w + .suggestions + .iter() + .map(|s| cp("Suggestion", "text.editRange", json!({ "id": w.id.0, "start": w.start, "end": w.end, "insert": s }))) + .collect(); + if v.is_empty() { + v.push(Item::Header("No Suggestions")); + } + v.extend([ + Sep, + cp("Ignore All", "text.spelling.ignoreAll", json!({ "word": w.word })), + cp("Add to Dictionary", "text.spelling.addWord", json!({ "word": w.word })), + Sep, + ]); + v +} + /// The locked objects under the pointer at the right-click, each unlocked as the Layers panel's /// lock column does. `unlock_item_label` shows each object's name. fn unlock_items(app: &VectorcraftApp) -> Vec { diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 36811ba4c..5d203fe87 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -1,6 +1,6 @@ use super::context::context_items; use super::invoke::click_target; -use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, unlock_item_label}; +use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, spelling_item_label, unlock_item_label}; use super::state::{shortcut_of, shown_state}; use super::tree::menu_tree_named; use super::{Entry, Item, Serialize, Value, VectorcraftApp}; @@ -51,6 +51,7 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { source: label, label: effect_item_label(id, p) .or_else(|| unlock_item_label(app, id, p, crate::i18n::current())) + .or_else(|| spelling_item_label(id, p)) .unwrap_or_else(|| display_label(app, id, label)) .into(), shortcut: item_shortcut(id, p), @@ -129,7 +130,9 @@ fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Ve let Some((enabled, _)) = shown_state(app, id, p) else { continue }; out.push(MenuEntry { path: path.clone(), - label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)), + label: unlock_item_label(app, id, p, crate::i18n::Lang::EN) + .or_else(|| spelling_item_label(id, p)) + .unwrap_or_else(|| dynamic_label(app, id, l)), command: Some(id.to_string()), params: p.clone(), enabled, diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index e26d0bdec..148368a27 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -214,6 +214,12 @@ pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: Some(crate::panels::layers::shown_name(app, n, lang)) } +/// A context menu suggestion's label: the word it puts in (`text.editRange {insert}`), as it is, +/// untranslated ([`super::context`]'s `spelling_items`). +pub(super) fn spelling_item_label(id: &str, p: &Value) -> Option { + (id == "text.editRange").then(|| p.get("insert")?.as_str().map(str::to_string)).flatten() +} + /// The shortcut a menu item shows: its command's (items without params), a panel's for the /// Window menu's `window.panel {panel}` items, an effect's for the Effect menu's items, or a filter /// plug-in's for Object › Plug-ins' `plugin.dialog {id}` items. @@ -235,6 +241,9 @@ pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { /// Labels only the canvas context menu ([`context_items`]) shows, so the catalog tests can insist /// every one of them is translated (a test checks the list against the menu). pub const CONTEXT_LABELS: &[&str] = &[ + "Ignore All", + "Add to Dictionary", + "No Suggestions", "Isolate Selected Group", "Exit Isolation Mode", "Lock Artboards", diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 64574e52d..ce03daca5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -348,6 +348,21 @@ pub struct ReplaceFontWarning { pub styles: Vec<(String, String)>, } +/// A misspelled word right-clicked on the canvas ([`UiState::context_spelling`]). +#[derive(Clone, Debug, PartialEq)] +pub struct ContextSpelling { + /// The document's `uid`. + pub uid: u64, + /// The type object. + pub id: vectorcraft_doc::NodeId, + /// The word's bytes in the object's plain text, which `text.editRange` replaces. + pub start: usize, + pub end: usize, + pub word: String, + /// What the dictionary of the word's language suggests instead, best first. + pub suggestions: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct UiState { @@ -415,6 +430,10 @@ pub struct UiState { /// while it is open ([`crate::panels::artboards::MenuFacts`]). Not saved. #[serde(skip)] pub context_artboards: Option, + /// The word with Auto Spell Check's red line under the last right-click on the canvas, for the + /// context menu's suggestions, Ignore All and Add to Dictionary while it is open. Not saved. + #[serde(skip)] + pub context_spelling: Option, /// Which link statuses the active document's images and placed documents have, for Select › /// Linked, Embedded, Missing and Modified to be on only when some do (refreshed by /// [`crate::addons::select::link_status::refresh`]). Not saved. @@ -1002,6 +1021,7 @@ impl Default for UiState { context_locked: None, context_intertwine: None, context_artboards: None, + context_spelling: None, link_statuses: Default::default(), slot_tool: Default::default(), floating_flyouts: vec![], diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index 6dadd856c..0812f410e 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -204,7 +204,10 @@ fn labels(items: &[Item]) -> Vec<&'static str> { items .iter() .flat_map(|it| match it { + // A spelling suggestion shows the word it puts in, not its label. + Item::Cmd(_, "text.editRange", _) => vec![], Item::Cmd(l, ..) => vec![*l], + Item::Header(l) => vec![*l], Item::Sub(l, ch) => std::iter::once(*l).chain(labels(ch)).collect(), _ => vec![], }) @@ -370,6 +373,18 @@ fn every_context_label_is_a_menu_string() { app.run("layer.setProps", json!({"ids": [a.0], "locked": true})).unwrap(); app.ui.context_locked = Some((app.session.active().unwrap().uid, vec![a])); shown.extend(labels(&menus::context_items(&app))); + // A word Auto Spell Check underlined, with no suggestions: Ignore All, Add to Dictionary. + let text = NodeId(app.run("text.create", json!({"x": 10, "y": 200, "text": "Zqxvbtkw"})).unwrap()["id"].as_u64().unwrap()); + app.ui.context_spelling = Some(crate::state::ContextSpelling { + uid: app.session.active().unwrap().uid, + id: text, + start: 0, + end: 8, + word: "Zqxvbtkw".into(), + suggestions: vec![], + }); + shown.extend(labels(&menus::context_items(&app))); + app.ui.context_spelling = None; // The Lock Artboards and Unlock Artboards submenus of the Artboard tool. app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 80})).unwrap(); app.run("artboard.setProps", json!({"index": 0, "locked": true})).unwrap(); @@ -384,3 +399,110 @@ fn every_context_label_is_a_menu_string() { assert!(shown.contains(label), "`{label}` is listed but the context menu never shows it"); } } + +/// Point type holding `t` at (20, 60), with Auto Spell Check on. +fn spelled(app: &mut VectorcraftApp, t: &str) -> NodeId { + let id = NodeId(app.run("text.create", json!({"x": 20, "y": 60, "text": t, "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("select.none", json!({})).unwrap(); + app.run("edit.autoSpellCheck", json!({})).unwrap(); + id +} + +/// Where on screen the middle of bytes `a..b` of type object `id` is. +fn word_at(app: &VectorcraftApp, id: NodeId, a: usize, b: usize) -> Pos2 { + let st = app.session.active().unwrap(); + let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) else { panic!("type") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let q = vectorcraft_text::selection_quads(&lay, a, b)[0].map(|c| t.xf * c); + let c = Point::new(q.iter().map(|p| p.x).sum::() / 4.0, q.iter().map(|p| p.y).sum::() / 4.0); + screen(app, c.x, c.y) +} + +fn plain(app: &VectorcraftApp, id: NodeId) -> String { + match app.session.active().unwrap().doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.plain_text(), + _ => panic!("type"), + } +} + +/// With Auto Spell Check on, right-clicking an underlined word lists what the dictionary suggests +/// instead, then Ignore All and Add to Dictionary; a suggestion replaces the word in one undo step +/// (helpx: Check and correct spelling, "Correct spelling errors during Auto Spell Check"). +#[test] +fn right_click_on_an_underlined_word_offers_its_suggestions() { + let mut app = app(); + let id = spelled(&mut app, "the speling cat"); + let want: Vec = app.session.execute("text.spelling.suggest", &json!({"word": "speling"})).unwrap()["suggestions"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect(); + assert!(!want.is_empty(), "the dictionary has suggestions for `speling`"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = word_at(&app, id, 4, 11); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + for w in &want { + assert!(has(&texts, w), "`{w}` in {texts:?}"); + } + assert!(has(&texts, "Ignore All") && has(&texts, "Add to Dictionary"), "{texts:?}"); + // Agents see them at the top of the menu, a suggestion by its word. + let entries = menus::context_entries(&app); + assert_eq!((entries[0].label.as_str(), entries[0].command.as_deref()), (want[0].as_str(), Some("text.editRange"))); + let steps = app.session.active().unwrap().history.undo.len(); + let texts = click(&mut app, &ctx, at(&texts, &want[0]), PointerButton::Primary); + assert_eq!(plain(&app, id), format!("the {} cat", want[0]), "the word is replaced"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "in one undo step"); + assert!(!has(&texts, "Ignore All"), "the menu closed: {texts:?}"); +} + +/// Ignore All and Add to Dictionary from the menu: the word's line goes, in this document for +/// Ignore All, in every check for Add. A word with nothing to suggest says so. +#[test] +fn ignore_all_and_add_to_dictionary_from_the_context_menu() { + let mut app = app(); + let id = spelled(&mut app, "Qzvxtrwk and Kwqzvxtr"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = word_at(&app, id, 0, 8); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(has(&texts, "No Suggestions"), "{texts:?}"); + click(&mut app, &ctx, at(&texts, "Ignore All"), PointerButton::Primary); + assert!(app.session.active().unwrap().spelling_ignored.contains("qzvxtrwk")); + let p = word_at(&app, id, 13, 21); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + click(&mut app, &ctx, at(&texts, "Add to Dictionary"), PointerButton::Primary); + let added = craft_proof_has(&mut app, "kwqzvxtr"); + app.run("text.spelling.removeWord", json!({"word": "kwqzvxtr"})).unwrap(); + assert!(added, "added to the custom dictionary"); + // Nothing left underlined there: a right-click on it offers no spelling items. + let p = word_at(&app, id, 0, 8); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "{texts:?}"); +} + +fn craft_proof_has(app: &mut VectorcraftApp, w: &str) -> bool { + app.run("text.spelling.dictionary", json!({})).unwrap()["words"].as_array().unwrap().iter().any(|x| x == w) +} + +/// With Auto Spell Check off, or on a word that is right, a right-click lists no spelling items. +#[test] +fn no_spelling_items_off_a_misspelled_word_or_with_auto_spell_check_off() { + let mut app = app(); + let id = spelled(&mut app, "the Vqzxtrwk"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = word_at(&app, id, 0, 3); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "a right word: {texts:?}"); + app.run("edit.autoSpellCheck", json!({})).unwrap(); + frame( + &mut app, + &ctx, + vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + let p = word_at(&app, id, 4, 12); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "off: {texts:?}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 0939dbf3d..cd3a77db6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,10 +1,11 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; 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: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; 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, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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; estimate 1–2 h → 0; 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: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; 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, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence **Feature evidence last updated:** 2026-11-02. +| 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -449,7 +450,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Missing:** CJK vertical composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and - `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain), + `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain), Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). - **Impact:** professional typography and Japanese/Chinese publishing. diff --git a/docs/mcp.md b/docs/mcp.md index 66459255a..e3b7487dc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5976,6 +5976,10 @@ any case, while the document is open. It is not saved and not an undo step. In the app, Edit › Spelling › Auto Spell Check (the UI command `edit.autoSpellCheck`, which returns whether it is now on) underlines on the canvas the words `text.spelling.check` lists, in the visible type without effects, and keeps the lines up to date as the type, the custom dictionary and the Ignore All words change. +A right-click on an underlined word puts at the top of the canvas context menu the words `text.spelling.suggest` gives +for it (each a `text.editRange {id, start, end, insert}` item labelled with its word), or No Suggestions, then Ignore +All (`text.spelling.ignoreAll {word}`) and Add to Dictionary (`text.spelling.addWord {word}`); the context menu's +entries list them with their params. ## Justification options diff --git a/docs/roadmap.md b/docs/roadmap.md index 19369b9fa..62b7dc7d4 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-10 · **Change:** 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-10 · **Change:** 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) ## Feature submission evidence @@ -40,6 +40,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 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. | @@ -137,9 +138,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), 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 | 4–8 | | 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 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check (#4936; the dialogs and the right-click Spelling menu remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check (#4936; the dialogs and the right-click Spelling menu remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check (#4936; the dialogs and the right-click Spelling menu remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7610075f6..c99d61e83 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-10 · **Change:** 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-10 · **Change:** 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) ## Feature submission evidence @@ -41,6 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours 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. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, 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, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | @@ -213,15 +214,15 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -482,10 +483,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Appearance, transparency, graphic styles, masks | 5 | 98% | 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, Revolve (Classic), 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 and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| 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 and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (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 is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (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 is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | @@ -1896,7 +1897,9 @@ Edit › Spelling has the commands its Check Spelling dialog will use, as the pu The custom dictionary and Ignore All followed (2026-11-09), as the same page describes them: Add puts a word in the custom dictionary, which Edit Custom Dictionary adds to, deletes from and changes, and Ignore All skips a word in the current document (#4936). `text.spelling.addWord`, `removeWord` and `changeWord` keep the dictionary in craft-proof, which every language's check reads, and the desktop app saves it to `Custom Dictionary.txt` beside its preferences (none in runs without preferences); `text.spelling.dictionary` lists it. `text.spelling.ignoreAll` keeps a word for the active document while it is open, not saved and not undoable (`tests/spelling.rs`). The dialogs remain. -Auto Spell Check followed (2026-11-09): the same page has Edit › Spelling › Auto Spell Check underline potential spelling errors with a red line as you type, and says it isn't available for text with effects applied (#4936). `edit.autoSpellCheck` turns it on and off (off at first, saved with the view settings); the canvas draws a red wavy line under each word `text.spelling.check` would list in the visible type, leaves out type with an effect shown on it or on its fills and strokes, and checks a type object again when it is edited, when the custom dictionary changes and when Ignore All adds a word (`ui-egui/src/canvas/tests.rs`, `auto_spell_check_*`; `tests/spelling.rs`). The right-click Spelling menu on an underlined word (suggestions, Ignore All, Add to Dictionary) and the dialogs remain. +Auto Spell Check followed (2026-11-09): the same page has Edit › Spelling › Auto Spell Check underline potential spelling errors with a red line as you type, and says it isn't available for text with effects applied (#4936). `edit.autoSpellCheck` turns it on and off (off at first, saved with the view settings); the canvas draws a red wavy line under each word `text.spelling.check` would list in the visible type, leaves out type with an effect shown on it or on its fills and strokes, and checks a type object again when it is edited, when the custom dictionary changes and when Ignore All adds a word (`ui-egui/src/canvas/tests.rs`, `auto_spell_check_*`; `tests/spelling.rs`). The dialogs remain. + +The right-click followed (2026-11-10), as the same page's "Correct spelling errors during Auto Spell Check" describes it: right-click a word with a red underline, then choose a suggested word, Ignore All to skip it in the current document, or Add to Dictionary (#4936). The canvas context menu lists the words `text.spelling.suggest` gives for it in its run's language, each replacing the word with `text.editRange` as one undo step, or No Suggestions; then Ignore All (`text.spelling.ignoreAll`) and Add to Dictionary (`text.spelling.addWord`). It lists nothing for a word that is right, with Auto Spell Check off, or once the word under the right-click has changed (`ui-egui/src/tests/contextmenu.rs`). The dialogs and the context menu's Spelling › Auto Spell Check remain. ## Control bar: Paragraph section while type is selected (2026-11-06) diff --git a/docs/type-parity.md b/docs/type-parity.md index ea09866b4..fbfe1b2a6 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -47,7 +47,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | -| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. The Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu (suggestions, Ignore All, Add to Dictionary) remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`). | +| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary. The Check Spelling and Edit Custom Dictionary dialogs and the context menu's Spelling › Auto Spell Check remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click). | | Smart Punctuation, Change Case, Find & Replace | done | | | Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-09 | minor | Auto Spell Check (#4936): Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, without type with effects; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain. Scores and estimates unchanged. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | | 2026-11-09 | minor | Custom dictionary and Ignore All (#4936): `text.spelling.addWord`, `removeWord`, `changeWord` and `dictionary`, saved beside the preferences, and `text.spelling.ignoreAll` for the active document; the Check Spelling and Edit Custom Dictionary dialogs and Auto Spell Check remain. Scores and estimates unchanged. | From 4f4a447b05d072f594aad61188942e778413eb22 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:47:38 -0800 Subject: [PATCH 17/48] Font menu: a font field covered by an overlay needs the pointer moved again A closed font family field stayed ready for the wheel as long as it kept its place and drew in each pass. Coverage was checked only when a pointer move or wheel turn arrived. When a window, modal or popup covered a still pointer and went away with no input in between, the next plain wheel turn changed the selected type's font. The field now stops being ready, and a wheel stream it held ends, in any pass where the last pointer position is over the field but on another layer. The pointer has to move onto the field again before the wheel steps the font. --- crates/ui-egui/src/font_menu.rs | 15 ++++++----- crates/ui-egui/src/font_menu/tests.rs | 37 +++++++++++++++++++++++++++ docs/type-parity.md | 2 +- docs/ui-parity.md | 2 +- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index d0750d373..19d103a74 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -12,8 +12,8 @@ //! the mouse wheel turned over the closed field applies the next family (or previous one) to the //! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the //! pointer leaves the field or the text selection changes; steps stop at its first and last family. -//! The field takes the wheel only when the pointer was moved onto it after the field last moved and -//! the wheel stream began there. +//! The field takes the wheel only when the pointer was moved onto it after the field last moved or +//! was covered by a window or overlay, and the wheel stream began there. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -446,7 +446,7 @@ struct WheelField { /// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; -/// The field the pointer was last moved onto, while that field stays where it was. +/// The field the pointer was last moved onto, while that field stays where it was and uncovered. #[derive(Clone, Copy)] struct ArmedWheelField { id: egui::Id, @@ -732,6 +732,9 @@ fn remember_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool) -> Vec) -> Vec { let key = wheel_fields_id(ui.ctx().viewport_id()); let pass = ui.ctx().cumulative_pass_nr(); + // A window or overlay over the pointer covers the field even when no input arrives. + let pointer = ui.data_mut(|d| d.get_temp_mut_or_default::(key).pointer).or_else(|| ui.ctx().input(|i| i.pointer.latest_pos())); + let covered = pointer.is_some_and(|p| resp.interact_rect.contains(p) && ui.ctx().layer_id_at(p) != Some(resp.layer_id)); ui.data_mut(|d| { let mut fields: WheelFields = d.get_temp(key).unwrap_or_default(); if fields.pass != pass { @@ -742,7 +745,7 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men if let Some(armed) = &mut fields.armed && armed.id == resp.id { - if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 { + if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 && !covered { armed.pass = pass; } else { fields.armed = None; @@ -761,8 +764,8 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men /// Take the closed font fields' wheel events before egui smooths and combines them. Other /// events stay in the input, so scrolling elsewhere keeps its own delta and event order. A field /// takes a wheel stream only when the stream begins with the pointer over it and the pointer was -/// moved onto it after the field last moved. A stream that began anywhere else stays there until -/// it ends, and a stream that leaves its field does not return to it. +/// moved onto it after the field last moved or was covered. A stream that began anywhere else +/// stays there until it ends, and a stream that leaves its field does not return to it. pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { let key = wheel_fields_id(raw.viewport_id); let Some(mut fields) = ctx.data(|d| d.get_temp::(key)) else { return }; diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 6e0d3bba6..87c3feb06 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -358,6 +358,11 @@ fn activating_the_family_popup_leaves_its_wheel_input_available() { /// A family field at `top`, the input's clock at `time`. Returns the wheel events left in the /// input, the notches the field took and the point in the middle of the field. fn stream_frame(ctx: &egui::Context, top: Option, time: f64, events: Vec) -> (usize, i32, egui::Pos2) { + covered_stream_frame(ctx, top, false, time, events) +} + +/// [`stream_frame`], with an overlay over the whole field when `covered` is set. +fn covered_stream_frame(ctx: &egui::Context, top: Option, covered: bool, time: f64, events: Vec) -> (usize, i32, egui::Pos2) { let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() }; super::take_raw_wheel(ctx, &mut raw); let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count(); @@ -373,6 +378,12 @@ fn stream_frame(ctx: &egui::Context, top: Option, time: f64, events: Vec Date: Tue, 10 Nov 2026 03:09:31 +0000 Subject: [PATCH 18/48] PDF import: DeviceN images of one ink or of process plates keep their inks (#6348) An image in a DeviceN color space opened as an RGB image, losing its inks. One of one colorant now opens as a Separation image does since #6347: a grayscale image of its tints colorized by its ink. One of two to 32 colorants that are all process plates (the ink None may be among them) opens as a CMYK image, each colorant's tint in its plate, its Decode array applied (8 or 16 bits a sample). A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import/builder.rs | 4 +- crates/pdf/src/import/device.rs | 2 +- crates/pdf/src/import_image.rs | 94 ++++++++++++++++++++++++++++---- crates/pdf/src/tests/colorize.rs | 61 +++++++++++++++++++++ docs/file-format-parity.md | 5 +- 5 files changed, 149 insertions(+), 17 deletions(-) diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 5fb504fd9..b052ff7c3 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -585,13 +585,13 @@ impl<'p> Builder<'p> { Some(Paint::Pattern { pattern: name, xf }) } - /// Separation image `st` (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a + /// Image `st` of one ink (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a /// process plate or Registration, found by its colour space) and its tints as gray pixels /// ([`crate::import_image::tint_gray`]). `None` for another image, an ink whose colour can't /// be told, or tints that can't be read (with a warning): the image opens in RGB. The ink is /// only marked used once the tints are read. pub(super) fn separation_image(&mut self, st: &craft_pdf::syntax::object::Stream<'_>, w: u32, h: u32) -> Option<(Paint, Vec)> { - let space = self.colors.array_space(&crate::import_image::separation_space(st)?)?; + let space = self.colors.array_space(&crate::import_image::one_ink_space(st)?)?; let gray = crate::import_image::tint_gray(st, w, h).map_err(|why| self.warn(why)).ok()?; Some((self.colors.native(space, &[1.0])?.paint(), gray)) } diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index 731a74d07..2fb6bb9c8 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -250,7 +250,7 @@ impl<'a> Device<'a> for Builder<'_> { let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None }; return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask, None); } - // A Separation image keeps its ink: its tints as a grayscale image that the ink at + // An image of one ink keeps it: its tints as a grayscale image that the ink at // 100 % colorizes, as a mask painted in the ink comes in. Its mask becomes an // opacity mask, as a CMYK image's does. Read once per image: the decision is kept, // so one that opens in RGB stays RGB, and the gray pixels have their own key. diff --git a/crates/pdf/src/import_image.rs b/crates/pdf/src/import_image.rs index b5875e9bd..d376c2b31 100644 --- a/crates/pdf/src/import_image.rs +++ b/crates/pdf/src/import_image.rs @@ -3,9 +3,10 @@ //! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any //! other is decoded, its Decode array applied, and stored as a CMYK TIFF //! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer -//! carries it as the image's opacity mask ([`has_mask`]). A Separation image keeps its ink: its -//! tints are read as a grayscale image ([`tint_gray`]) that the ink colorizes. Every other image is -//! read as RGB by the interpreter. +//! carries it as the image's opacity mask ([`has_mask`]). A DeviceN image of process plates only +//! keeps them the same way, as a CMYK image ([`process_devicen`]). An image of one ink (a +//! Separation, or a DeviceN of one colorant) keeps it: its tints are read as a grayscale image +//! ([`tint_gray`]) that the ink colorizes. Every other image is read as RGB by the interpreter. use craft_pdf::syntax::Filter; use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams}; @@ -13,6 +14,8 @@ use craft_pdf::syntax::object::{Array, Dict, Name, Stream}; use vectorcraft_doc::ImageBlob; use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components}; +use crate::import_color::ink_names; + /// A CMYK image read as RGB: why. pub(crate) const UNREAD: &str = "CMYK images whose samples couldn't be read in CMYK were imported in RGB"; @@ -57,7 +60,7 @@ fn decode_ranges(d: &Dict<'_>) -> [(f32, f32); 4] { pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result, &'static str> { let d = st.dict(); if !cmyk_space(d) { - return Ok(None); + return process_devicen(st, width, height); } let ranges = decode_ranges(d); let filters = st.filters(); @@ -94,19 +97,86 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result(st: &Stream<'a>) -> Option> { +/// The image's colour space when it lays one ink: a Separation (`[/Separation name alt tint]`), or +/// a DeviceN of one colorant (`[/DeviceN [name] alt tint …]`). `None` for the ink `None`, which +/// paints nothing (ISO 32000-1 §8.6.6.4): the interpreter draws it. +pub(crate) fn one_ink_space<'a>(st: &Stream<'a>) -> Option> { let d = st.dict(); let a = d.get::>(key(d, b"ColorSpace", b"CS"))?; - let one_ink = { - let mut it = a.flex_iter(); - it.next::>()?.as_ref() == b"Separation" && it.next::>()?.as_ref() != b"None" - }; + let one_ink = matches!(ink_names(&mut a.flex_iter()).as_ref().map(|(_, names)| names.as_slice()), Some([name]) if name != "None"); one_ink.then_some(a) } -/// The tints of the one-component image `st` (`width` × `height` pixels, a Separation image's) +/// The CMYK channel of each colorant of the image's DeviceN space (`None` for the ink `None`) when +/// it has two or more colorants and all are process plates, or `None`. One colorant is an image of +/// one ink ([`one_ink_space`]). +fn process_plates(d: &Dict<'_>) -> Option>> { + let a = d.get::>(key(d, b"ColorSpace", b"CS"))?; + let (device_n, names) = ink_names(&mut a.flex_iter())?; + // PDF's own limit is 32 colorants. + if !device_n || names.len() < 2 || names.len() > 32 { + return None; + } + let plates = names + .iter() + .map(|n| match n.as_str() { + "None" => Some(None), + n => ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == n).map(Some), + }) + .collect::>>()?; + plates.iter().any(Option::is_some).then_some(plates) +} + +/// A DeviceN image of process plates only ([`process_plates`]) in CMYK: each colorant's tint in +/// its plate, its Decode array applied, as a CMYK TIFF. `Ok(None)` for any other image, `Err` (a +/// warning) when its samples can't be read. +fn process_devicen(st: &Stream<'_>, width: u32, height: u32) -> Result, &'static str> { + let d = st.dict(); + let Some(plates) = process_plates(d) else { return Ok(None) }; + let bpc = d.get::(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8); + if !matches!(bpc, 8 | 16) { + return Err(UNREAD); + } + let n = u64::from(width) * u64::from(height); + if n == 0 || n > MAX_CMYK_PIXELS { + return Err(UNREAD); + } + let comps = plates.len(); + let ranges: Vec<(f32, f32)> = match d.get::>(key(d, b"Decode", b"D")).map(|a| a.iter::().collect::>()) { + Some(v) if v.len() == 2 * comps && v.iter().all(|x| x.is_finite()) => v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + _ => vec![(0.0, 1.0); comps], + }; + let size = usize::from(bpc / 8); + let params = + ImageDecodeParams { is_indexed: false, bpc: Some(bpc), num_components: u8::try_from(comps).ok(), target_dimension: None, width, height }; + let decoded = st.decoded_image(¶ms).map_err(|_| UNREAD)?; + // An image codec lays its samples out its own way. + if decoded.image_data.is_some() { + return Err(UNREAD); + } + // Within `MAX_CMYK_PIXELS` and 32 colorants of 2 bytes this fits a 64-bit `usize`, not always a + // 32-bit one (the web build): checked. + let len = usize::try_from(n).ok().and_then(|n| n.checked_mul(comps)?.checked_mul(size)).ok_or(UNREAD)?; + let raw = decoded.data.get(..len).ok_or(UNREAD)?; + let value = |s: &[u8]| match *s { + [hi, lo] => u16::from_be_bytes([hi, lo]) as f32 / 65535.0, + [v] => v as f32 / 255.0, + _ => 0.0, + }; + let mut inks = Vec::with_capacity(n as usize * 4); + for px in raw.chunks_exact(size * comps) { + let mut cmyk = [0.0f32; 4]; + for ((s, plate), (lo, hi)) in px.chunks_exact(size).zip(&plates).zip(&ranges) { + if let Some(c) = plate.and_then(|p| cmyk.get_mut(p)) { + *c = c.max((lo + value(s) * (hi - lo)).clamp(0.0, 1.0)); + } + } + inks.extend(cmyk.map(|v| (v * 255.0).round() as u8)); + } + Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)).map(Some).ok_or(UNREAD) +} + +/// The tints of the one-component image `st` (`width` × `height` pixels, an image of one ink) /// as a grayscale image: white where it lays no ink, black at 100 %, its Decode array applied. /// `Err` (a warning) when its samples can't be read. pub(crate) fn tint_gray(st: &Stream<'_>, width: u32, height: u32) -> Result, &'static str> { diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 2e4469dbb..97388ea31 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -432,3 +432,64 @@ fn a_separation_image_that_cant_be_read_stays_rgb() { assert!(r.document.swatches.iter().all(|s| !s.spot), "no swatch for an ink nothing uses"); assert_eq!(r.warnings.iter().filter(|w| w.contains("spot ink")).count(), 1, "{:?}", r.warnings); } + +/// A Type 4 tint transform as an object, `code` its program. +fn type4(code: &str, inputs: usize) -> String { + let domain = "0 1 ".repeat(inputs); + format!("<< /FunctionType 4 /Domain [{}] /Range [0 1 0 1 0 1 0 1] /Length {} >>\nstream\n{code}\nendstream", domain.trim_end(), code.len()) +} + +/// The imported images' blobs, in paint order. +fn image_blobs(d: &Document) -> Vec<(ImageBlob, bool)> { + let mut out = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + out.push((d.images[&im.key].clone(), n.extras().colorize.is_some())); + } + }); + } + out +} + +/// A DeviceN image of one colorant opens as a Separation image does: its tints, colorized by the ink. +#[test] +fn a_devicen_image_of_one_ink_opens_colorized_by_it() { + let space = "[/DeviceN [/SpotRed] /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]"; + let d = separation_page(space, 4, 8, "", "0055AAFF", &[]); + let images = imported_images(&d); + assert_eq!(images.len(), 1); + assert_eq!(images[0].1, [255, 170, 85, 0]); + assert!(matches!(&images[0].0, Some(Paint::Solid { swatch: Some(s), .. }) if s == "SpotRed"), "{:?}", images[0].0); +} + +/// A DeviceN image of process plates only opens in CMYK, each colorant's tint in its plate. +#[test] +fn a_devicen_image_of_process_plates_opens_in_cmyk() { + let space = format!("[/DeviceN [/Cyan /Magenta] /DeviceCMYK {} 0 R]", first_extra(1) + 1); + // Cyan and Magenta → (c, m, 0, 0); pixels: none, cyan, magenta, both. + let d = separation_page(&space, 4, 8, "", "0000FF0000FFFFFF", &[&type4("{ 0 0 }", 2)]); + let blobs = image_blobs(&d); + assert_eq!(blobs.len(), 1); + let (blob, colorized) = &blobs[0]; + assert!(blob.is_cmyk() && !colorized, "a CMYK image"); + let inks = blob.cmyk().unwrap().data; + assert_eq!(inks, [0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 255, 255, 0, 0]); + + // 16 bits a sample, the ink None among the plates, and a Decode array that inverts Cyan. + let space = format!("[/DeviceN [/Cyan /None /Black] /DeviceCMYK {} 0 R]", first_extra(1) + 1); + let d = separation_page(&space, 2, 16, "/Decode [1 0 0 1 0 1]", "FFFF0000000000000000FFFF", &[&type4("{ exch pop 0 0 3 -1 roll }", 3)]); + let blobs = image_blobs(&d); + assert_eq!(blobs[0].0.cmyk().unwrap().data, [0, 0, 0, 0, 255, 0, 0, 255]); +} + +/// A DeviceN image of a spot ink and a process plate (a duotone) still opens as RGB: an image can +/// hold one ink or the process plates, not both. +#[test] +fn a_duotone_devicen_image_still_opens_as_rgb() { + let space = format!("[/DeviceN [/Black /SpotRed] /DeviceCMYK {} 0 R]", first_extra(1) + 1); + let d = separation_page(&space, 2, 8, "", "FF0000FF", &[&type4("{ dup 3 -1 roll 0 4 1 roll }", 2)]); + let blobs = image_blobs(&d); + assert_eq!(blobs.len(), 1); + assert!(!blobs[0].0.is_cmyk() && !blobs[0].1, "RGB, not colorized"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1f911f6e5..10dded623 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -283,7 +283,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) | | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | -| PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | +| PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | @@ -327,6 +327,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | PDF import: an image in a DeviceN space of one colorant opens as a Separation image does (#6314): a grayscale image of its tints colorized by its ink. One of two or more colorants that are all process plates (Cyan, Magenta, Yellow, Black, and the ink `None`) opens as a CMYK image, each colorant's tint in its plate and its Decode array applied (8 or 16 bits a sample; one that can't be read, such as a JPEG, opens in RGB with the CMYK images' warning). Both used to open as RGB images, losing their inks. A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB: an image holds one ink or the process plates, not both (#6348). Tests `colorize::a_devicen_image_of_one_ink_opens_colorized_by_it`, `colorize::a_devicen_image_of_process_plates_opens_in_cmyk`, `colorize::a_duotone_devicen_image_still_opens_as_rgb` | | 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) | | 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) | | 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) | From 2c6a0b88db838366852926c482abbe4b7a01e655 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 18:42:36 -0800 Subject: [PATCH 19/48] Number fields: a panel scrolled past a number field keeps scrolling A number field took every wheel turn that came while the pointer was over it. When a panel scrolled a field under a still pointer, the field stepped its value with each turn that reached it. Number fields now follow the rule #6375 gave the closed font family field. A field takes the wheel only when the wheel stream began over it and the pointer was moved onto it, or pressed on it, after the field last moved or was covered by a window or overlay. A stream that began anywhere else stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field. A stream that leaves its field does not return to it. It leaves when the pointer moves off the field or out of the window, and when the field moves or misses a layout pass. Trackpad momentum never starts a stream on a field. When some of a frame's wheel turns land on the field and some land elsewhere, the field takes none of them and the panel scrolls with all of them. The rule and its 0.5 s are VectorCraft's own. Moving the pointer from one number field to another within 0.5 s of the last wheel turn now scrolls the panel until the wheel pauses or the pointer is pressed on the new field. The stream state and the 0.5 s gap move to widgets/wheel_stream.rs, which both kinds of field use. --- crates/ui-egui/src/font_menu.rs | 50 +--- crates/ui-egui/src/font_menu/tests.rs | 47 +++- .../ui-egui/src/panel_docking/native_tests.rs | 4 +- crates/ui-egui/src/tests/font_menu.rs | 2 +- crates/ui-egui/src/tests/numfields.rs | 233 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 4 + crates/ui-egui/src/widgets/numeric.rs | 210 +++++++++++++++- crates/ui-egui/src/widgets/wheel_stream.rs | 66 +++++ docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 10 files changed, 556 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/src/widgets/wheel_stream.rs diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index d0750d373..c59ef6f8c 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -26,6 +26,7 @@ use vectorcraft_text::FontClass; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets; +use crate::widgets::{ArmedWheelField, WheelStream, WheelStreamOwner}; /// What happened in the menu this frame. #[derive(Clone, Debug, PartialEq)] @@ -443,54 +444,6 @@ struct WheelField { layer: egui::LayerId, } -/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. -pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; - -/// The field the pointer was last moved onto, while that field stays where it was. -#[derive(Clone, Copy)] -struct ArmedWheelField { - id: egui::Id, - rect: egui::Rect, - pass: u64, -} - -#[derive(Clone, Copy, PartialEq)] -enum WheelStreamOwner { - Field(egui::Id), - Elsewhere, -} - -/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included. -#[derive(Clone, Copy, Default)] -struct WheelStream { - last: Option<f64>, - owner: Option<WheelStreamOwner>, -} - -impl WheelStream { - /// Note wheel input at `now`; a gap since the last input starts a new stream with no owner. - fn note_input(&mut self, now: f64) { - if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) { - self.owner = None; - } - self.last = Some(now); - } - - /// Leave the rest of the stream to the panel when `field` holds it. - fn release(&mut self, field: egui::Id) { - if self.owner == Some(WheelStreamOwner::Field(field)) { - self.owner = Some(WheelStreamOwner::Elsewhere); - } - } - - /// Leave the rest of the stream to the panel when any field holds it. - fn release_any(&mut self) { - if let Some(WheelStreamOwner::Field(field)) = self.owner { - self.release(field); - } - } -} - #[derive(Clone)] struct QueuedWheel { #[cfg(not(target_arch = "wasm32"))] @@ -941,6 +894,7 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { fields.pointer = pointer; ctx.data_mut(|d| d.insert_temp(key, fields)); if captured { + ctx.data_mut(|d| d.insert_temp(widgets::taken_from_raw_id(raw.viewport_id), now)); ctx.request_repaint(); } } diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 6e0d3bba6..75d2810e5 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -396,6 +396,45 @@ fn stream_context(top: f32) -> (egui::Context, egui::Pos2) { (ctx, point) } +/// A wheel stream the family field took stays its own when the pointer moves onto a number field +/// beside it: the number field steps only after the wheel pauses for the stream gap. +#[test] +fn a_stream_the_family_field_took_leaves_a_number_field_beside_it() { + let ctx = egui::Context::default(); + let value = std::cell::Cell::new(10.0); + let (family, number) = (std::cell::Cell::new(egui::Pos2::ZERO), std::cell::Cell::new(egui::Pos2::ZERO)); + let frame = |time: f64, events: Vec<egui::Event>| { + let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() }; + super::take_raw_wheel(&ctx, &mut raw); + let mut notches = 0; + let mut out = ctx.run_ui(raw, |ui| { + let resp = ui.put(egui::Rect::from_min_size(egui::pos2(10.0, 40.0), egui::vec2(120.0, 24.0)), egui::Button::new("Family")); + family.set(resp.rect.center()); + for turn in super::remember_wheel_field(ui, &resp, true) { + if let super::WheelInput::Notches(n) = turn { + notches += n; + } + } + ui.add_space(40.0); + let top = ui.cursor().top(); + if let Some(v) = crate::widgets::plain_field(ui, "size", value.get(), "", 0, 80.0) { + value.set(v); + } + number.set(egui::pos2(ui.min_rect().left() + 20.0, (top + ui.min_rect().bottom()) / 2.0)); + }); + out.textures_delta.clear(); + notches + }; + for i in 0..3 { + frame(100.0 + f64::from(i), vec![]); + } + assert_eq!(frame(110.0, vec![egui::Event::PointerMoved(family.get()), plain_turn()]), 1, "the family field took the turn"); + frame(110.1, vec![egui::Event::PointerMoved(number.get()), plain_turn()]); + assert_eq!(value.get(), 10.0, "the stream began on the family field"); + frame(110.1 + crate::widgets::WHEEL_STREAM_GAP + 0.1, vec![plain_turn()]); + assert_eq!(value.get(), 9.0, "a new stream"); +} + /// A field that moved under a pointer that stayed put leaves the wheel, including a reported /// pointer position that did not change, until the pointer really moves. #[test] @@ -479,7 +518,7 @@ fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() { let (ctx, point) = stream_context(40.0); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); assert_eq!((left, notches), (0, 1)); - let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + super::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + crate::widgets::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "the same stream"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "a new stream, with the pointer still on the field"); @@ -491,14 +530,14 @@ fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() { fn a_wheel_stream_that_began_elsewhere_stays_there_until_a_gap() { let (ctx, point) = stream_context(40.0); let away = point + egui::vec2(0.0, 200.0); - let step = super::WHEEL_STREAM_GAP * 0.8; + let step = crate::widgets::WHEEL_STREAM_GAP * 0.8; let (left, _, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), plain_turn()]); assert_eq!(left, 1, "the stream began off the field"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + step, vec![egui::Event::PointerMoved(point), plain_turn()]); assert_eq!((left, notches), (1, 0), "a nudge onto the field does not take a running stream"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![plain_turn()]); assert_eq!((left, notches), (1, 0), "each event renews the stream"); - let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + super::WHEEL_STREAM_GAP, vec![plain_turn()]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + crate::widgets::WHEEL_STREAM_GAP, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "the stream ended, and the pointer was moved onto the field"); } @@ -548,7 +587,7 @@ fn trackpad_momentum_stays_with_its_stream() { use egui::{MouseWheelSource::Momentum, MouseWheelSource::Trackpad, TouchPhase}; let (ctx, point) = stream_context(40.0); let away = point + egui::vec2(0.0, 200.0); - let step = super::WHEEL_STREAM_GAP * 0.8; + let step = crate::widgets::WHEEL_STREAM_GAP * 0.8; stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), stream_turn(TouchPhase::Move, Trackpad, -1.0)]); // The fingers lift with no delta, the pointer moves onto the field, and momentum follows. stream_frame(&ctx, Some(40.0), 110.0 + step, vec![stream_turn(TouchPhase::End, Trackpad, 0.0)]); diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index de0bc1db7..db1e6be36 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -1054,7 +1054,7 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { h.host.borrow_mut().child_overlay = None; h.frame(vec![]); - h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP; + h.host.borrow_mut().time += crate::widgets::WHEEL_STREAM_GAP; h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ Event::PointerMoved(pointer + vec2(1.0, 0.0)), Event::MouseWheel { @@ -1088,7 +1088,7 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { h.host.borrow_mut().child_transparent_overlay = None; h.host.borrow_mut().child_pass_through_overlay = Some((viewport, field.expand(10.0))); h.frames(2); - h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP; + h.host.borrow_mut().time += crate::widgets::WHEEL_STREAM_GAP; h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ Event::PointerMoved(pointer + vec2(2.0, 0.0)), Event::MouseWheel { diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index 431f2f982..ea79c1d5c 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -410,7 +410,7 @@ fn wheel_events_use_the_pointer_position_when_they_occurred() { assert_eq!(first_style(&app, id).font_family, "Inter", "the wheel was outside the field"); assert_eq!(app.session.active().unwrap().history.undo.len(), before); assert!(ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. }))), "outside wheel input is left for its owner"); - let after_gap = |ctx: &egui::Context| Some(ctx.input(|i| i.time) + crate::font_menu::WHEEL_STREAM_GAP); + let after_gap = |ctx: &egui::Context| Some(ctx.input(|i| i.time) + crate::widgets::WHEEL_STREAM_GAP); let later = after_gap(&ctx); panel_frame_at(&ctx, &mut app, later, vec![wheel(line, -1.0, Default::default()), egui::Event::PointerMoved(outside)], "Inter", &draw); assert_ne!(first_style(&app, id).font_family, "Inter", "the wheel was over the field before the pointer left"); diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index d83938555..45801c22e 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1007,6 +1007,239 @@ fn the_wheel_scrolls_the_panel_unless_over_a_numeric_field() { } } +/// A wheel notch of `dy` lines (up positive) from `source`, with no pointer movement. +fn notch(dy: f32, source: egui::MouseWheelSource) -> Event { + Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, source } +} + +/// A notch down from a mouse wheel. +fn notch_down() -> Event { + notch(-1.0, egui::MouseWheelSource::Wheel) +} + +/// A panel scrolled with a still pointer keeps scrolling when a number field slides under the +/// pointer, and the field keeps its value. The field used to take each notch that came while it +/// was under the pointer. +#[test] +fn scrolling_a_panel_past_a_number_field_under_a_still_pointer_keeps_the_value() { + let mut f = Field::new(); + let offset = Cell::new(0.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.add_space(60.0); + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + ui.add_space(600.0); + v + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![Event::PointerMoved(Pos2::new(20.0, 20.0))], &draw); + let mut stepped = None; + for _ in 0..30 { + stepped = stepped.or(f.frame(vec![notch_down()], &draw)); + } + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((stepped, value.get()), (None, 10.0), "the field kept its value"); + assert!(offset.get() > 200.0, "the panel scrolled the field past the pointer: {}", offset.get()); +} + +/// A number field that slid under a still pointer takes the wheel only after the pointer is moved +/// onto it; an event at the same position is not a move. Until then the panel scrolls. +#[test] +fn a_number_field_that_slid_under_a_still_pointer_steps_after_the_pointer_moves() { + let mut f = Field::new(); + let top = Cell::new(60.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + ui.add_space(top.get()); + widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)) + }; + f.frame(vec![], &draw); + let at = Pos2::new(20.0, 20.0); + f.frame(vec![Event::PointerMoved(at)], &draw); + top.set(0.0); + f.frame(vec![], &draw); + assert!(f.rect.get().contains(at), "the field is under the pointer"); + for _ in 0..2 { + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the pointer did not move"); + } + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at), notch_down()], &draw), None, "the same position"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + let moved = Pos2::new(21.0, 20.0); + assert_eq!(f.frame(vec![Event::PointerMoved(moved), notch_down()], &draw), Some(9.0)); + // The field moves again under the pointer, in the frame of a turn: the turn is not the field's, + // and the field waits for another move. + top.set(2.0); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the field moved in the frame of the turn"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the field moved"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at), notch_down()], &draw), Some(8.0)); +} + +/// A wheel stream stays with what it began on until it pauses for the stream gap: one that began +/// off the field never steps it, one that left the field (the pointer moved off it or out of the +/// window) does not come back to it, and trackpad momentum never starts one on the field. +#[test] +fn a_number_field_wheel_stream_stays_with_what_it_began_on() { + use egui::MouseWheelSource::{Momentum, Trackpad}; + let mut f = Field::new(); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + f.frame(vec![], &draw); + let (on, off) = (f.rect.get().center(), Pos2::new(300.0, 150.0)); + let gap = |f: &mut Field| f.time += crate::widgets::WHEEL_STREAM_GAP; + // Began on the field, left it, came back within the stream. + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + assert_eq!(f.frame(vec![Event::PointerMoved(off), notch_down()], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "left its field"); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0), "a new stream"); + // Began off the field. + f.frame(vec![Event::PointerMoved(off)], &draw); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "began off the field"); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(7.0)); + // The pointer left the window. + assert_eq!(f.frame(vec![Event::PointerGone], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "left the window"); + // Momentum after a pause starts no stream on the field, and the stream it starts stays off it. + gap(&mut f); + assert_eq!(f.frame(vec![notch(-1.0, Momentum)], &draw), None, "momentum"); + assert_eq!(f.frame(vec![notch(-1.0, Trackpad)], &draw), None, "the momentum's stream"); + gap(&mut f); + assert_eq!(f.frame(vec![notch(-1.0, Trackpad)], &draw), Some(6.0)); + assert_eq!(value.get(), 6.0); +} + +/// A frame whose turns begin on the field and go on after the pointer moved off it scrolls the +/// panel with all of them and leaves the value. +#[test] +fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() { + let mut f = Field::new(); + let offset = Cell::new(0.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + ui.add_space(600.0); + v + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + let (on, off) = (Pos2::new(20.0, 12.0), Pos2::new(20.0, 80.0)); + let events = vec![Event::PointerMoved(on), notch_down(), Event::PointerMoved(off), notch_down()]; + assert_eq!(f.frame(events, &draw), None); + assert!(offset.get() > 0.0, "the panel scrolled in the same frame"); + assert_eq!(value.get(), 10.0); +} + +/// A number field that missed a pass (its panel was hidden) waits for the pointer to move onto it +/// again before it takes the wheel, with or without another number field drawn meanwhile. +#[test] +fn a_number_field_that_missed_a_pass_waits_for_a_pointer_move() { + for other in [false, true] { + let mut f = Field::new(); + let shown = Cell::new(true); + let draw = |ui: &mut egui::Ui| { + let v = if shown.get() { widgets::plain_field(ui, "f", 10.0, "", 0, 80.0) } else { None }; + if other { + ui.add_space(100.0); + widgets::plain_field(ui, "other", 1.0, "", 0, 80.0); + } + v + }; + f.frame(vec![], &draw); + let at = Pos2::new(20.0, 12.0); + f.frame(vec![Event::PointerMoved(at)], &draw); + shown.set(false); + f.frame(vec![], &draw); + shown.set(true); + f.frame(vec![], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "missed a pass, other field {other}"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at + vec2(1.0, 0.0)), notch_down()], &draw), Some(9.0), "other field {other}"); + } +} + +/// Two number fields, `a` above `b`; the frame returns `b`'s new value, and `a`'s goes in `a`. +fn two_fields<'a>(a: &'a Cell<f64>, b: &'a Cell<f64>, rects: &'a Cell<[Rect; 2]>) -> impl Fn(&mut egui::Ui) -> Option<f64> + 'a { + move |ui: &mut egui::Ui| { + if let Some(v) = widgets::plain_field(ui, "a", a.get(), "", 0, 80.0) { + a.set(v); + } + let first = ui.min_rect(); + ui.add_space(40.0); + let top = ui.cursor().top(); + let v = widgets::plain_field(ui, "b", b.get(), "", 0, 80.0).inspect(|&v| b.set(v)); + rects.set([first, Rect::from_x_y_ranges(first.x_range(), top..=ui.min_rect().bottom())]); + v + } +} + +/// A stream that began on one number field scrolls the panel when the pointer moves onto another +/// field, until the wheel pauses for the stream gap or the pointer is pressed on the other field. +#[test] +fn a_press_on_a_number_field_gives_it_the_running_wheel_stream() { + let mut f = Field::new(); + let (a, b, rects) = (Cell::new(10.0), Cell::new(20.0), Cell::new([Rect::NOTHING; 2])); + let draw = two_fields(&a, &b, &rects); + f.frame(vec![], &draw); + let [on_a, on_b] = rects.get().map(|r| r.center()); + f.frame(vec![Event::PointerMoved(on_a), notch_down()], &draw); + assert_eq!(a.get(), 9.0, "the stream began on a"); + assert_eq!(f.frame(vec![Event::PointerMoved(on_b), notch_down()], &draw), None, "moved onto b within the stream"); + let press = |pressed| Event::PointerButton { pos: on_b, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + f.frame(vec![press(true), press(false)], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(19.0), "pressed on b"); + assert_eq!(a.get(), 9.0); + // A press on the field that holds the stream keeps it there. + f.frame(vec![press(true), press(false)], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(18.0)); +} + +/// A window or overlay that comes and goes over a still pointer, with no input while it is up, +/// leaves the number field under it waiting for the pointer to move onto it again. +#[test] +fn a_number_field_covered_by_an_overlay_waits_for_a_pointer_move() { + let mut f = Field::new(); + let value = Cell::new(10.0); + let covered = Cell::new(false); + let draw = |ui: &mut egui::Ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + if covered.get() { + let cover = ui.min_rect().expand(5.0); + egui::Area::new(egui::Id::unique("numfields overlay")).order(egui::Order::Foreground).fixed_pos(cover.min).show(ui.ctx(), |ui| { + ui.allocate_exact_size(cover.size(), egui::Sense::click()); + }); + } + v + }; + f.frame(vec![], &draw); + let at = f.rect.get().center(); + f.frame(vec![Event::PointerMoved(at)], &draw); + covered.set(true); + f.frame(vec![], &draw); + f.frame(vec![], &draw); + covered.set(false); + f.frame(vec![], &draw); + f.frame(vec![], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the overlay covered the field"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at + vec2(1.0, 0.0)), notch_down()], &draw), Some(9.0)); +} + /// A wheel turn over an unfocused field steps it without consuming an arrow key in the same frame. #[test] fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f3243c75..c70cca173 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -20,6 +20,7 @@ mod numeric; mod panel_chrome; mod text; mod toggles; +mod wheel_stream; pub use blend::{ TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point, @@ -72,6 +73,9 @@ pub use text::{ text_presets_named, }; pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button}; +#[cfg(test)] +pub(crate) use wheel_stream::WHEEL_STREAM_GAP; +pub(crate) use wheel_stream::{ArmedWheelField, WheelStream, WheelStreamOwner, taken_from_raw_id}; /// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also /// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 04ac7160f..36184395b 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -5,6 +5,7 @@ use super::panel_chrome::{ use super::text::{ after_shift_enter, focus_field, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_armed_focus, take_dialog_focus, }; +use super::{ArmedWheelField, WheelStream, WheelStreamOwner}; use super::{CornerRadius, RangeInclusive, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Unit, id_with, pos2, scrub, vec2}; /// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it @@ -247,9 +248,10 @@ pub(super) fn shown_alike(show: impl Fn(f64) -> String) -> impl Fn(f64, f64) -> /// a weight stops at 0, a miter limit at 1), and a plain step also stops at each of `stops` /// ([`step_through`]). The new value is applied at once, as in /// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing -/// replaces it when focused. Returns the new number when a step was taken. While the pointer is -/// over an enabled field the wheel is the field's (the panel under it doesn't scroll), without -/// taking keyboard focus. Arrow keys still require keyboard focus. +/// replaces it when focused. Returns the new number when a step was taken. An enabled field takes +/// the wheel without taking keyboard focus, and the panel under it doesn't scroll, when the wheel +/// stream began over the field and the pointer was moved onto it, or pressed on it, after the +/// field last moved or was covered ([`number_wheel_turns`]). Arrow keys still require keyboard focus. fn step_value( ui: &Ui, resp: &Response, @@ -266,8 +268,10 @@ fn step_value( } let focused = ui.memory(|m| m.has_focus(resp.id)); use egui::{Event, Key, MouseWheelUnit}; - let wheel = resp.contains_pointer(); - if !focused && !wheel { + let (taken, owns) = number_wheel_turns(ui, resp); + let wheel = !taken.is_empty(); + let hold = wheel || owns && resp.contains_pointer(); + if !focused && !hold { return None; } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is @@ -277,14 +281,18 @@ fn step_value( // Each press and each turn of whole notches in the order they came: its modifiers and size from // [`STEPS`], and how many steps up (down when negative). let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); + let mut taken = taken; ui.input_mut(|i| { i.events.retain(|e| { + // Each taken event is matched once by its value, so wheel events that other code adds + // or removes before the field runs do not shift which events the field takes. + let mine = taken.iter().position(|t| t == e).map(|at| taken.swap_remove(at)).is_some(); let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; let n = match e { Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0, Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0, - Event::MouseWheel { unit, delta, .. } if wheel => { + Event::MouseWheel { unit, delta, .. } if mine => { // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; notches += match unit { @@ -302,7 +310,7 @@ fn step_value( } false }); - if wheel { + if hold { // The rest of a turn egui spreads over the next frames scrolls nothing either. i.smooth_scroll_delta = egui::Vec2::ZERO; } @@ -335,6 +343,194 @@ fn step_value( Some(v) } +/// An enabled number field as the wheel finds it. +#[derive(Clone, Copy)] +struct WheelTarget { + id: egui::Id, + rect: Rect, + layer: egui::LayerId, +} + +/// Where the pointer was, the field it was last moved onto, and the running wheel stream. +#[derive(Clone, Copy, Default)] +struct WheelWalk { + pointer: Option<egui::Pos2>, + armed: Option<ArmedWheelField>, + stream: WheelStream, +} + +/// The number fields' wheel state in one viewport. +#[derive(Clone, Default)] +struct NumberWheel { + /// The pass whose events `walk` and `taken` cover. + pass: Option<u64>, + /// The enabled number fields drawn in the pass before `pass`. + before: Vec<WheelTarget>, + /// The enabled number fields drawn so far in `pass`. + drawn: Vec<WheelTarget>, + walk: WheelWalk, + /// Each wheel event of `pass` that a field takes, with the field. + taken: Vec<(egui::Id, egui::Event)>, +} + +/// The wheel events of this pass that number field `resp` takes, and whether the field holds the +/// wheel stream after them. The first field drawn in a pass reads the pass's events for every +/// field, using where the fields were drawn in the pass before. A field takes a wheel stream only +/// when the stream begins with the pointer over it and the pointer was moved onto it, or pressed +/// on it, after the field last moved or was covered by a window or overlay. A stream that began +/// anywhere else stays there until it ends ([`WheelStream`]) or the pointer is pressed on a field, +/// and a stream that leaves its field does not return to it. Trackpad momentum never starts a +/// stream on a field. +fn number_wheel_turns(ui: &Ui, resp: &Response) -> (Vec<egui::Event>, bool) { + let ctx = ui.ctx(); + let key = egui::Id::unique("number-field-wheel").with(ctx.viewport_id()); + let pass = ctx.cumulative_pass_nr(); + let font_key = super::taken_from_raw_id(ctx.viewport_id()); + let fresh = ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::<NumberWheel>(key); + if state.pass == Some(pass) { + return None; + } + let follows = state.pass.is_some_and(|last| last + 1 == pass); + state.before = if follows { std::mem::take(&mut state.drawn) } else { Vec::new() }; + state.drawn.clear(); + state.pass = Some(pass); + if !follows { + state.walk.pointer = None; + state.walk.armed = None; + state.walk.stream.release_any(); + } + // Turns the font family field took from the raw input belong to its stream. + let font_took = d.remove_temp::<f64>(font_key); + let state = d.get_temp_mut_or_default::<NumberWheel>(key); + if let Some(at) = font_took { + state.walk.stream.note_taken_elsewhere(at); + } + // A field that missed a pass may have moved. + if let Some(armed) = state.walk.armed + && !state.before.iter().any(|t| t.id == armed.id) + { + state.walk.armed = None; + state.walk.stream.release(armed.id); + } + Some((state.walk, state.before.clone())) + }); + let walked = fresh.map(|(walk, before)| { + let (events, now) = ui.input(|i| (i.events.clone(), i.time)); + walk_wheel(ctx, walk, &events, &before, now, pass) + }); + let target = WheelTarget { id: resp.id, rect: resp.interact_rect, layer: resp.layer_id }; + let (armed_here, pointer) = ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::<NumberWheel>(key); + if let Some((walk, taken)) = walked { + state.walk = walk; + state.taken = taken; + } + (state.walk.armed.is_some_and(|armed| armed.id == resp.id), state.walk.pointer) + }); + // A window or overlay over the pointer covers the armed field even when no input arrives. + let covered = armed_here && pointer.is_some_and(|p| target.rect.contains(p) && ctx.layer_id_at(p) != Some(target.layer)); + ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::<NumberWheel>(key); + if let Some(armed) = &mut state.walk.armed + && armed.id == resp.id + { + if armed.rect == target.rect && !covered { + armed.pass = pass; + } else { + // The field moved or is covered, so the pass's turns stay with the panel. + state.walk.armed = None; + state.walk.stream.release(resp.id); + state.taken.retain(|&(id, _)| id != resp.id); + } + } + state.drawn.push(target); + let mut mine = Vec::new(); + state.taken.retain(|(id, event)| { + if *id == resp.id { + mine.push(event.clone()); + } + *id != resp.id + }); + (mine, state.walk.stream.owner == Some(WheelStreamOwner::Field(resp.id))) + }) +} + +/// `walk` after `events`, with `fields` where the pointer finds them, at input time `now`; and the +/// wheel events a field takes (see [`NumberWheel::taken`]). +fn walk_wheel( + ctx: &egui::Context, + mut walk: WheelWalk, + events: &[egui::Event], + fields: &[WheelTarget], + now: f64, + pass: u64, +) -> (WheelWalk, Vec<(egui::Id, egui::Event)>) { + let under = |p: Option<egui::Pos2>| { + let p = p?; + let layer = ctx.layer_id_at(p); + fields.iter().rev().find(|t| t.rect.contains(p) && layer == Some(t.layer)).copied() + }; + let mut taken = Vec::new(); + let mut left = false; + for event in events { + match event { + egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } => { + let pressed = matches!(event, egui::Event::PointerButton { pressed: true, .. }); + if walk.pointer != Some(*p) || pressed { + let field = under(Some(*p)); + walk.armed = field.map(|t| ArmedWheelField { id: t.id, rect: t.rect, pass }); + if let Some(WheelStreamOwner::Field(id)) = walk.stream.owner + && field.is_none_or(|t| t.id != id) + { + walk.stream.release(id); + } + // A press on a field gives it the rest of a running stream. + if pressed + && let Some(t) = field + && walk.stream.owner != Some(WheelStreamOwner::Field(t.id)) + { + walk.stream.owner = None; + } + } + walk.pointer = Some(*p); + } + egui::Event::PointerGone => { + walk.pointer = None; + walk.armed = None; + walk.stream.release_any(); + } + egui::Event::MouseWheel { delta, source, .. } => { + walk.stream.note_input(now); + let field = under(walk.pointer); + if *delta != egui::Vec2::ZERO { + let starts = field + .filter(|t| walk.armed.is_some_and(|armed| armed.id == t.id) && *source != egui::MouseWheelSource::Momentum) + .map_or(WheelStreamOwner::Elsewhere, |t| WheelStreamOwner::Field(t.id)); + let owner = *walk.stream.owner.get_or_insert(starts); + if field.is_none_or(|t| owner != WheelStreamOwner::Field(t.id)) { + walk.stream.owner = Some(WheelStreamOwner::Elsewhere); + } + } + if let Some(t) = field + && walk.stream.owner == Some(WheelStreamOwner::Field(t.id)) + { + taken.push((t.id, event.clone())); + } else { + left |= *delta != egui::Vec2::ZERO; + } + } + _ => {} + } + } + // egui smooths a pass's wheel input as a whole before any field runs, so a field cannot take + // part of it: a pass whose wheel input also scrolls something else is left whole to the panel. + if left { + taken.clear(); + } + (walk, taken) +} + /// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter /// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A /// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). diff --git a/crates/ui-egui/src/widgets/wheel_stream.rs b/crates/ui-egui/src/widgets/wheel_stream.rs new file mode 100644 index 000000000..fe4820042 --- /dev/null +++ b/crates/ui-egui/src/widgets/wheel_stream.rs @@ -0,0 +1,66 @@ +//! Wheel streams for the fields that step on the wheel (number fields and the closed font family +//! field). A field takes a wheel stream only when the stream begins with the pointer over it and +//! the pointer was moved onto it after the field last moved. + +/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. +pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; + +/// Where the closed font family field, which takes its wheel events from the raw input, keeps the +/// input time of the last one it took in `viewport`, so number fields see that stream too. +pub(crate) fn taken_from_raw_id(viewport: egui::ViewportId) -> egui::Id { + egui::Id::unique("wheel-taken-from-raw").with(viewport) +} + +/// The field the pointer was last moved onto, while that field stays where it was. +#[derive(Clone, Copy)] +pub(crate) struct ArmedWheelField { + pub(crate) id: egui::Id, + pub(crate) rect: egui::Rect, + pub(crate) pass: u64, +} + +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum WheelStreamOwner { + Field(egui::Id), + Elsewhere, +} + +/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included. +#[derive(Clone, Copy, Default)] +pub(crate) struct WheelStream { + last: Option<f64>, + pub(crate) owner: Option<WheelStreamOwner>, +} + +impl WheelStream { + /// Note wheel input at `now`; a gap since the last input starts a new stream with no owner. + pub(crate) fn note_input(&mut self, now: f64) { + if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) { + self.owner = None; + } + self.last = Some(now); + } + + /// Note wheel input at `at` that another kind of field took: the stream it belongs to is not + /// one of this kind's fields. + pub(crate) fn note_taken_elsewhere(&mut self, at: f64) { + if self.last.is_none_or(|last| last <= at) { + self.note_input(at); + } + self.owner = Some(WheelStreamOwner::Elsewhere); + } + + /// Leave the rest of the stream to the panel when `field` holds it. + pub(crate) fn release(&mut self, field: egui::Id) { + if self.owner == Some(WheelStreamOwner::Field(field)) { + self.owner = Some(WheelStreamOwner::Elsewhere); + } + } + + /// Leave the rest of the stream to the panel when any field holds it. + pub(crate) fn release_any(&mut self) { + if let Some(WheelStreamOwner::Field(field)) = self.owner { + self.release(field); + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7610075f6..5d91cd61b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -804,7 +804,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. A field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere, such as a panel scrolled under a still pointer, stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field (VectorCraft's own rule and value). - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44a25a180..922c5f974 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -267,7 +267,7 @@ tools, never checked side by side) isn't captured row by row. | Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. This rule and its 0.5 s are VectorCraft's choices. Other font-picking menus and modifier-wheel input keep their existing behavior. | | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | -| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | +| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field, so a panel scrolled under a still pointer keeps scrolling past the field. This rule and its 0.5 s are VectorCraft's choices. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | From 2d5fc74cbf553de8b50a2d271b93d54230a7efee Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:29:02 -0800 Subject: [PATCH 20/48] Scripts: a document a script opens counts toward the run's growth limit A document that document.open or file.new adds during a run counts whole toward the growth allowance: every object in its layers, symbols and patterns, opacity masks included, and its document data. --- crates/engine/src/script/mod.rs | 25 ++++++++++++++++----- crates/engine/src/tests/script_run.rs | 31 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 4b5380319..ee351d298 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -520,6 +520,14 @@ fn raise_ids(s: &mut Session) { s.script_high_water = marks; } +/// Every object in `d`: the art of its layers, its symbols and its patterns, opacity masks included. +fn all_objects(d: &crate::Document) -> u64 { + let layers = d.layers.iter().map(|n| n.count_with_masks()); + let symbols = d.symbols.iter().map(|s| s.art.count_with_masks()); + let patterns = d.patterns.iter().flat_map(|p| &p.art).map(|n| n.count_with_masks()); + layers.chain(symbols).chain(patterns).map(|n| n as u64).sum() +} + fn note_ids(s: &mut Session) { for st in &s.docs { let mark = s.script_high_water.entry(st.uid).or_insert(0); @@ -551,11 +559,18 @@ fn step(s: &mut Session, host: &Host, id: &str, params: &Value) -> Result<(Value note_ids(s); let (mut objects, mut bytes) = (0, 0); for st in &s.docs { - if let Some((_, d, ids)) = before.iter().find(|(uid, ..)| *uid == st.uid) - && !Arc::ptr_eq(d, &st.doc) - { - objects += st.doc.peek_next_id().saturating_sub(*ids); - bytes += d.retained_bytes_each(&st.doc).1 as u64; + match before.iter().find(|(uid, ..)| *uid == st.uid) { + Some((_, d, ids)) if !Arc::ptr_eq(d, &st.doc) => { + objects += st.doc.peek_next_id().saturating_sub(*ids); + bytes += d.retained_bytes_each(&st.doc).1 as u64; + } + Some(_) => {} + // A document the command opened or made counts whole. + None if mutating => { + objects += all_objects(&st.doc); + bytes += crate::Document::new(1.0, 1.0).retained_bytes_each(&st.doc).1 as u64; + } + None => {} } } Ok((v, objects, bytes, save)) diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 7912c7b1d..566e36145 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -601,6 +601,37 @@ fn a_run_with_nobody_to_ask_ends_at_its_growth_allowance() { assert_eq!(state(&s), before); } +#[test] +fn a_document_a_run_opens_counts_toward_its_growth_allowance() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let rects: String = (0..30).map(|i| format!(r#"<rect x="{i}" y="0" width="1" height="1"/>"#)).collect(); + let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">{rects}</svg>"#); + let data = vectorcraft_format::base64_encode(svg.as_bytes()); + let mut s = two_docs(); + let before = state(&s); + let source = format!("try {{ vc.run('document.open', {{name: 'a.svg', dataBase64: '{data}'}}); }} catch (e) {{}}"); + let e = run(&mut s, &source).unwrap_err().to_string(); + assert!(e.contains("objects and data"), "{e}"); + assert_eq!(state(&s), before); + + // A document whose art is all in a symbol counts it too. + let mut maker = Session::new(); + maker.execute("file.new", &json!({})).unwrap(); + for x in 0..30 { + maker.execute("shape.rectangle", &rect(x)).unwrap(); + } + maker.execute("select.all", &json!({})).unwrap(); + let instance = maker.execute("symbol.new", &json!({"name": "Many"})).unwrap()["id"].as_u64().unwrap(); + maker.execute("edit.clear", &json!({"ids": [instance]})).unwrap(); + assert_eq!(objects(&maker, 0), 0); + let data = maker.execute("document.serialize", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let source = format!("vc.run('document.open', {{name: 'b.vectorcraft', dataBase64: '{data}'}});"); + let e = run(&mut s, &source).unwrap_err().to_string(); + TEST_GROWTH.with(|g| g.set(None)); + assert!(e.contains("objects and data"), "{e}"); + assert_eq!(state(&s), before); +} + #[test] fn a_person_can_let_a_growing_run_go_on_or_stop_it() { TEST_GROWTH.with(|g| g.set(Some(20))); From 9172950ad50349b186f23dba6d4d18ba429ad5c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:34:24 -0800 Subject: [PATCH 21/48] MCP: command listings give each command with a schema an inputSchema engine.commands, command_list and list_commands now list an inputSchema next to schema for each command with a parameter schema. It is the same JSON Schema 2020-12 schema with the root type "object", the form an MCP tool's inputSchema takes (https://modelcontextprotocol.io/specification/2026-07-28/schema); schema keeps the root type ["object", "null"] when no parameter is required. A command without a schema lists neither field. tools/list is unchanged, and the commands are still run with command_run or run_command. brief now leaves out schema and inputSchema as well as the params text; a listing filtered to the command's id gives them. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd.rs | 18 ++++++++- crates/engine/src/schemas.rs | 60 +++++++++++++++++++++++++++++ crates/mcp/src/tests/conventions.rs | 49 ++++++++++++++++++++++- crates/mcp/src/tools/definitions.rs | 4 +- crates/mcp/src/tools/dispatch.rs | 9 +++-- docs/mcp.md | 15 ++++++-- docs/target-app-parity.md | 1 + 7 files changed, 144 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 4042f0a13..a68703f11 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -149,11 +149,26 @@ pub struct CommandInfo { /// The parameters' schema as a JSON Schema 2020-12 object schema, for a command that has one. #[serde(skip_serializing_if = "Option::is_none")] pub schema: Option<Value>, + /// The same schema in the form an MCP tool's `inputSchema` takes ([`input_schema`]). + #[serde(rename = "inputSchema", skip_serializing_if = "Option::is_none")] + pub input_schema: Option<Value>, +} + +/// `schema` as an MCP tool input schema: the root `type` is `"object"`, since the arguments are +/// always an object, and every other keyword is kept. The root of [`CommandInfo::schema`] also +/// takes null when no parameter is required. +pub(crate) fn input_schema(schema: &Value) -> Value { + let mut o = schema.clone(); + if let Some(map) = o.as_object_mut() { + map.insert("type".into(), "object".into()); + } + o } impl CommandSpec { pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); + let schema = self.schema.map(vectorcraft_apischema::ParamSchema::to_json); CommandInfo { id: self.id, label: self.label, @@ -162,7 +177,8 @@ impl CommandSpec { params: self.params, enabled: e.is_ok(), disabled_reason: e.err(), - schema: self.schema.map(vectorcraft_apischema::ParamSchema::to_json), + input_schema: schema.as_ref().map(input_schema), + schema, } } } diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index c70c4bb82..a24613a07 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -323,6 +323,66 @@ mod tests { assert!(without.get("schema").is_none()); } + /// The listing gives each command with a schema an `inputSchema`, the form an MCP tool's input + /// schema takes: the same schema with the root `type` `"object"`, and every other keyword kept, + /// nested parameters, required names, defaults and VectorCraft's own fields included. A command + /// with no schema lists neither. + #[test] + fn engine_commands_list_an_input_schema_with_an_object_root() { + let s = Session::new(); + let listed: Vec<Value> = s.commands().iter().map(|c| serde_json::to_value(c).unwrap()).collect(); + let mut with_schema = 0; + for c in &listed { + let id = c["id"].as_str().unwrap(); + let Some(schema) = c.get("schema") else { + assert!(c.get("inputSchema").is_none(), "{id}"); + continue; + }; + with_schema += 1; + let input = c.get("inputSchema").unwrap_or_else(|| panic!("{id} has no inputSchema")); + assert_eq!(input["type"], "object", "{id}"); + assert_eq!(input["$schema"], vectorcraft_apischema::DIALECT, "{id}"); + check_schema(input, id).unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert!(!instance_valid(input, &Value::Null), "{id}: the arguments are an object"); + let without_type = |v: &Value| { + let mut v = v.clone(); + v.as_object_mut().map(|o| o.remove("type")); + v + }; + assert_eq!(without_type(schema), without_type(input), "{id}"); + } + assert_eq!(with_schema, schemas().len()); + let get = |id: &str| listed.iter().find(|c| c["id"] == id).unwrap(); + // No required parameter: the schema also takes null, the input schema does not. + let list = get("view.saved.list"); + assert_eq!(list["schema"]["type"], json!(["object", "null"])); + assert_eq!(list["inputSchema"]["type"], "object"); + // Required parameters and their constraints are kept. + let new_view = get("view.saved.new"); + assert_eq!(new_view["inputSchema"]["required"], json!(["center", "zoom"])); + assert_eq!(new_view["inputSchema"]["properties"], new_view["schema"]["properties"]); + // Nested objects in an array keep their keys. + let edit = &get("view.saved.edit")["inputSchema"]["properties"]["edits"]; + assert!(edit.to_string().contains("newName"), "{edit}"); + assert!(get("file.new").get("inputSchema").is_none()); + } + + /// A deprecated command and a deprecated parameter keep their deprecation in the input schema. + #[test] + fn an_input_schema_keeps_deprecation() { + static PARAMS: [Param; 2] = [Param::new("old", Type::Boolean).optional().deprecated(1), Param::new("on", Type::Boolean).optional()]; + let schema = ParamSchema::new(&PARAMS, "Old command.").deprecated(1).to_json(); + let input = crate::cmd::input_schema(&schema); + check_schema(&input, "synthetic").unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert_eq!(schema["type"], json!(["object", "null"])); + assert_eq!(input["type"], "object"); + assert_eq!(input["deprecated"], true); + assert_eq!(input["x-deprecatedSince"], 1); + assert_eq!(input["properties"]["old"], schema["properties"]["old"]); + assert_eq!(input["properties"]["old"]["deprecated"], true); + assert_eq!(input["description"], "Old command."); + } + /// The listing says what each View command changes when it runs. Only the commands that make an /// undo step list `document`. #[test] diff --git a/crates/mcp/src/tests/conventions.rs b/crates/mcp/src/tests/conventions.rs index 929851917..0b7352bf4 100644 --- a/crates/mcp/src/tests/conventions.rs +++ b/crates/mcp/src/tests/conventions.rs @@ -57,18 +57,63 @@ fn command_listings_take_query_menu_and_brief() { let menu: Vec<&str> = c["menu"].as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect(); assert_eq!(menu.get(..2), Some(&["Object", "Path"][..]), "{c}"); } - // `brief` drops the params text and nothing else. + // `brief` drops the params text and the schemas, and nothing else. let full = payload(&call(&mut s, "command_list", json!({}))); let brief = payload(&call(&mut s, "command_list", json!({"brief":true}))); assert_eq!(ids(&full), ids(&brief)); assert!(full.as_array().unwrap().iter().any(|c| c.get("params").is_some())); - assert!(brief.as_array().unwrap().iter().all(|c| c.get("params").is_none() && c.get("label").is_some())); + assert!(full.as_array().unwrap().iter().any(|c| c.get("inputSchema").is_some())); + assert!( + brief + .as_array() + .unwrap() + .iter() + .all(|c| c.get("params").is_none() && c.get("schema").is_none() && c.get("inputSchema").is_none() && c.get("label").is_some()) + ); assert!(brief.to_string().len() * 2 < full.to_string().len(), "brief is much shorter"); // The other listing takes them too. let r = payload(&call(&mut s, "list_commands", json!({"query":"rectangle","brief":true,"menu":""}))); assert_eq!(ids(&r["commands"]), ids(&by_filter)); } +#[path = "../../../apischema/src/conformance.rs"] +mod conformance; + +/// Both listings give a command with a parameter schema an `inputSchema` with an object root, +/// which follows the 2020-12 rules, and the filters select the same commands as before. +#[test] +fn command_listings_carry_input_schemas() { + let mut s = Server::new(Box::new(Headless::with_document())); + let list = payload(&call(&mut s, "list_commands", json!({"filter":"view.saved"}))); + let bare = payload(&call(&mut s, "command_list", json!({"filter":"view.saved"}))); + assert_eq!(list["commands"], bare); + assert_eq!(list["count"], bare.as_array().unwrap().len()); + let commands = bare.as_array().unwrap(); + let with_input: Vec<&Value> = commands.iter().filter(|c| c.get("inputSchema").is_some()).collect(); + assert!(with_input.len() >= 3, "{bare}"); + for c in with_input { + let input = &c["inputSchema"]; + assert_eq!(input["type"], "object", "{c}"); + conformance::check_schema(input, c["id"].as_str().unwrap()).unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert!(!conformance::instance_valid(input, &Value::Null)); + assert_eq!(conformance::instance_valid(&c["schema"], &Value::Null), c["schema"]["type"] != "object", "{c}"); + } + let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").unwrap(); + assert_eq!(saved_list["schema"]["type"], json!(["object", "null"])); + assert_eq!(saved_list["inputSchema"]["type"], "object"); + // A command without a schema lists neither. + let new = payload(&call(&mut s, "command_list", json!({"filter":"file.new","enabled_only":true}))); + let new = new.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap().clone(); + assert!(new.get("schema").is_none() && new.get("inputSchema").is_none(), "{new}"); + // `brief`, `menu` and the enabled filter select the same ids with and without the schemas. + let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].clone()).collect::<Vec<_>>(); + let view = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true}))); + let view_brief = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true,"brief":true}))); + assert_eq!(ids(&view["commands"]), ids(&view_brief["commands"])); + assert_eq!(view["count"], view_brief["count"]); + assert!(view_brief["commands"].as_array().unwrap().iter().all(|c| c.get("inputSchema").is_none() && c.get("schema").is_none())); +} + #[test] fn conventions_commands_batch_and_bounded_preview() { let mut s = Server::new(Box::new(Headless::with_document())); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 616e32aae..8fe45b83e 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -98,7 +98,7 @@ pub fn tool_definitions() -> Vec<Value> { tool( "command_list", "List commands", - "Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", + "Discover command ids, labels, parameter descriptions and enabled state. A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", obj( json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), &[], @@ -156,7 +156,7 @@ pub fn tool_definitions() -> Vec<Value> { tool( "list_commands", "List commands", - "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", + "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", obj( json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), &[], diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index eabd73e3e..b1c5af70b 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -10,8 +10,7 @@ pub(super) const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")"; /// The `brief` argument of the command listings. -pub(super) const BRIEF: &str = - "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it"; +pub(super) const BRIEF: &str = "Leave out each command's params description and its schema and inputSchema (the bulk of the catalogue); run command_list with the id as filter to read them"; /// The `max_side` argument of the window captures. pub(super) const MAX_SIDE_WINDOW: &str = @@ -30,7 +29,7 @@ pub(super) fn trace_views(a: &Args) -> Result<bool, String> { /// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the /// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params` -/// text: the full catalogue is too large for many clients, #2173). +/// text, `schema` or `inputSchema`: the full catalogue is too large for many clients, #2173). fn filter_commands(all: Value, a: &Args) -> Value { let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); let needle = text("filter").or_else(|| text("query")); @@ -63,7 +62,9 @@ fn filter_commands(all: Value, a: &Args) -> Value { .filter(hit) .map(|mut c| { if brief && let Some(o) = c.as_object_mut() { - o.remove("params"); + for key in ["params", "schema", "inputSchema"] { + o.remove(key); + } } c }) diff --git a/docs/mcp.md b/docs/mcp.md index 2618ebfa4..e818579c8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -369,10 +369,10 @@ The older names below remain listed because existing workflows use them. | Tool | Arguments and result | |---|---| -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array. `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array. A command with a parameter schema lists `schema` and `inputSchema` (see [Parameter schemas](#parameter-schemas)). `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)). `run_command` runs a command the same way and takes its id as `command` | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` | | `render_preview` | Optional `max_side` (1–4096, default 1024), `artboard` (0-based, default 0) and `traceViews`; inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). With `traceViews: true`, Image Trace objects draw in the view set with `imageTrace.setView` (outlines, the source image…), as in the app's window; by default they draw their tracing result. Artboards too large to render within the allocation bound return a tool error | @@ -446,7 +446,7 @@ opens the picker through `run_command` with the remote backend. | Tool | Arguments | Notes | |---|---|---| -| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | +| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement, and `schema` and `inputSchema` for a command with a parameter schema (the params doc and both schemas are left out with `brief`). | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | @@ -1403,6 +1403,15 @@ JSON Schema defines one, and the command receives `2`. An `x-lenient` parameter' and the engine also passes a value of another type to the command. The command's `params` text equals the text the schema writes, and a test checks that they are the same. +The `engine.commands` list and the `command_list` and `list_commands` tools also give such a command an `inputSchema` +field: the same schema in the form of an MCP tool's `inputSchema` +(https://modelcontextprotocol.io/specification/2025-06-18/schema), whose root `type` is always `"object"` +(https://modelcontextprotocol.io/specification/2026-07-28/schema). It differs from `schema` only in the root `type`, +which is `"object"` also when no parameter is required; every other keyword, `$schema` and the `x-` fields included, +is the same. A command without a schema lists neither field. The commands remain commands run with `command_run` +or `run_command`: `tools/list` does not list them as tools. With `brief`, the listings leave out `schema` and +`inputSchema` as well as the `params` text; a listing with the command's id as `filter` gives them. + The engine checks the params of such a command against its schema before the command runs, for every new call: from the desktop app, the control channel, MCP or the CLI, and when one command runs another. The steps of `command.batch` and `action.batch` are checked before the first step runs. A value of the wrong type, out of range or not in its list, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17cc2849b..31b606249 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -669,6 +669,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. +- **Input schemas in the command listings:** `engine.commands` and the MCP tools `command_list` and `list_commands` give each command with a parameter schema an `inputSchema` next to `schema`: the same schema with the root `type` `"object"`, the form an MCP tool's input schema takes ([MCP schema reference](https://modelcontextprotocol.io/specification/2026-07-28/schema)). A command without a schema lists neither, and `brief` leaves out both schemas with the `params` text. Tests: `schemas::tests::engine_commands_list_an_input_schema_with_an_object_root` and `schemas::tests::an_input_schema_keeps_deprecation` in `vectorcraft-engine`, and `tests::conventions::command_listings_carry_input_schemas` in `vectorcraft-mcp`, which check every listed input schema against the JSON Schema 2020-12 rules. - **Automation:** - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value. From 1bf6d2e7930f6345a243ffd8cdc8006edf9c6aa1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:47:04 -0800 Subject: [PATCH 22/48] Transform: a butt-capped curve with a handle on its end point stops at that end With Use Preview Bounds on, a curve piece that bends more tightly than half the stroke weight after the last split had its box grown by half the weight on every side. A handle on an end point makes the curve's first or last control difference zero, so such a curve always reached that fallback and measured past its butt end: a vertical 2 pt curve from (50,10) to (50,60) with its first handle on (50,10) measured Rect(49, 9, 51, 60) instead of Rect(49, 10, 51, 60). The fallback now measures the piece from the directions its tangent can take, the nonzero control differences, when they all lie within a quarter turn of their sum. Pieces whose tangent turns further are still grown on every side. --- crates/doc/src/reach.rs | 67 +++++++++++++++++++++++++++++++++++++-- docs/target-app-parity.md | 1 + 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 4b5f1d435..99fb6b03d 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -115,14 +115,38 @@ fn gentle(seg: &PathSeg, side: f64) -> bool { slowest > 0.0 && side * bend < slowest * slowest } +/// The box a stroke reaching `side` from `seg` paints across it, from the directions its tangent +/// can take: the tangent is a mix of the nonzero control differences, so when their directions all +/// lie within a quarter turn of the directions' sum, every point across the stroke is in `seg`'s +/// box moved `side` along a normal of one of those directions. `None` when they do not. A handle on +/// its end point adds no direction, so a straight curve with one measures as a line. +fn turning_box(seg: &PathSeg, side: f64) -> Option<Rect> { + let c = seg.to_cubic(); + let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2]; + let moving = || d.iter().map(|v| (*v, v.hypot())).filter(|(_, h)| *h > 0.0).map(|(v, h)| v / h); + let along = unit(moving().fold(Vec2::ZERO, |s, v| s + v)); + if moving().any(|v| v.dot(along) <= 0.0) { + return None; + } + let angle = |v: &Vec2| along.cross(*v).atan2(along.dot(*v)); + let lo = moving().min_by(|a, b| angle(a).total_cmp(&angle(b)))?; + let hi = moving().max_by(|a, b| angle(a).total_cmp(&angle(b)))?; + let n = |t: Vec2| (Vec2::new(t.y, -t.x) * side).to_point(); + let one_side = round_join_box(Point::ZERO, lo, hi, side).unwrap_or_else(|| Rect::from_points(n(lo), n(hi))); + let reach = one_side.union(Rect::new(-one_side.x1, -one_side.y1, -one_side.x0, -one_side.y0)); + let b = ParamCurveExtrema::bounding_box(seg); + Some(Rect::new(b.x0 + reach.x0, b.y0 + reach.y0, b.x1 + reach.x1, b.y1 + reach.y1)) +} + /// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`: /// the corners at the ends and the points across the stroke where the tangent is square to an axis. /// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending -/// that much is grown by `side` on every side. +/// that much is measured by [`turning_box`], or grown by `side` on every side when its tangent +/// turns too far. fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect { if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) { if depth == 0 { - return ParamCurveExtrema::bounding_box(seg).inflate(side, side); + return turning_box(seg, side).unwrap_or_else(|| ParamCurveExtrema::bounding_box(seg).inflate(side, side)); } return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1)); } @@ -614,6 +638,45 @@ mod tests { assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}"); } + #[test] + fn a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end() { + let butt = stroke(2.0, |s| s.cap = LineCap::Butt); + // A straight curve whose first or last handle sits on its end point measures like a line. + let mut first = BezPath::new(); + first.move_to((50.0, 10.0)); + first.curve_to((50.0, 10.0), (50.0, 40.0), (50.0, 60.0)); + let mut last = BezPath::new(); + last.move_to((50.0, 10.0)); + last.curve_to((50.0, 20.0), (50.0, 60.0), (50.0, 60.0)); + for bp in [first, last] { + assert_eq!(butt.bounds_on(&bp, bp.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + } + // A bend that leaves its start straight up from a handle on the start point: the stroke + // reaches only a little below the start, not half the weight. + let mut bend = BezPath::new(); + bend.move_to((0.0, 50.0)); + bend.curve_to((0.0, 50.0), (0.0, 0.0), (50.0, 0.0)); + let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&bend, bend.bounding_box()); + assert!(b.y1 >= 50.0 && b.y1 < 50.2, "{b:?}"); + assert!((b.x0 + 2.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.x1 - 50.0).abs() < 1e-9, "{b:?}"); + // Nearly a cusp: the curve turns back at its lowest point, where its tangent is level and + // the stroke reaches the full half weight below it. + let (a0, a1, a2) = (1e5 * 81.0 / 256.0, -1e5 * 9.0 / 8.0, 1e5); + let x = |i: f64| 2e-9 * i / 3.0; + let mut cusp = BezPath::new(); + cusp.move_to((x(0.0), a0)); + cusp.curve_to((x(1.0), a0 + a1 / 3.0), (x(2.0), a0 + 2.0 * a1 / 3.0 + a2 / 3.0), (x(3.0), a0 + a1 + a2)); + let b = butt.bounds_on(&cusp, cusp.bounding_box()); + assert!(b.y0 <= -1.0 + 1e-6, "{b:?}"); + // A curve far shorter than the weight still reaches half the weight above where its + // tangent is level. + let mut tiny = BezPath::new(); + tiny.move_to((0.0, 0.9e-12)); + tiny.curve_to((0.3e-12, -0.5e-12), (1.7e-12, -0.2e-12), (2e-12, 1e-12)); + let b = butt.bounds_on(&tiny, tiny.bounding_box()); + assert!(b.y0 <= -1.0 + 1e-9, "{b:?}"); + } + #[test] fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() { let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c3edab442..577986c92 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1052,6 +1052,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Blends, envelopes and objects with a warp or another geometry effect are members as they draw (a blend's steps, an envelope's distorted art, the warped outline), and the area follows an edit of the blend, envelope or warp; Release gives them back as they were. Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: the question marks on mixed member modes, hit-testing by the area (a click hits the members). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). + - A butt-capped curve whose first or last handle sits on its end point also stops at that end: a straight 50 pt curve with a 2 pt stroke and its first handle on its start point measures 50 pt tall, not 51 pt (doc `reach::tests::a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end`). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). From 7a5925359c96afdc3eb856a343c4f96e9765dce3 Mon Sep 17 00:00:00 2001 From: makesomethingshit <junsoo1172@gmail.com> Date: Tue, 10 Nov 2026 12:47:27 +0900 Subject: [PATCH 23/48] #6307: Keep spinner evidence beside numeric field history --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 7c68b4817..0082fcf33 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -192,12 +192,12 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| -| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. | | 2026-11-09 | minor | M4/G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | | 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | From 55e6c0a5b1530d8e39d8c7d04b9364a646c81728 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:47:40 -0800 Subject: [PATCH 24/48] Revolve: a hidden Revolve's profile is measured before the effects after it effect.setParams checks a Revolve before the edit, so turning a hidden Revolve back on measured its profile after every geometry effect on the object, those after the Revolve included, and could refuse a profile that draws. The check now measures the profile after only the geometry effects before the Revolve, hidden or shown, as drawing does. An object holds at most one Revolve, hidden ones included, so the check finds the same one that draws. --- crates/effects/src/revolve.rs | 20 +++++++++++++++++++- crates/engine/src/cmd/effectcmd/tests.rs | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 99a6e053e..a081070a6 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -70,7 +70,8 @@ pub fn revolve_options(p: &Value) -> Revolve { pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; let effects = &n.appearance.effects; - let before = effects.iter().position(|e| e.visible && e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); + // The profile is measured before the object's one Revolve, hidden or shown. + let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); if is_container(n) { const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors"; let mut shapes = members(n, "Revolve")?; @@ -812,6 +813,23 @@ mod tests { assert!(validate_revolve(&object, &json!({})).is_err(), "a reshaped object not yet revolved"); } + #[test] + fn validation_of_a_hidden_revolve_leaves_out_the_geometry_effects_after_it() { + let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); + let mut hidden = crate::new_effect(REVOLVE, &json!({})).unwrap(); + hidden.visible = false; + let mut object = (*star(1)).clone(); + object.appearance.effects = vec![hidden.clone(), roughen.clone()]; + assert_eq!(validate_revolve(&object, &json!({})), Ok(()), "an object whose Revolve is shown again"); + let mut group = group_with(json!({}), vec![star(1)]); + group.appearance.effects = vec![hidden, roughen]; + assert_eq!(validate_revolve(&group, &json!({})), Ok(()), "a group whose Revolve is shown again"); + for n in [&mut object, &mut group] { + n.appearance.effects[0].visible = true; + assert!(revolve_art(n).is_some(), "shown, the Revolve draws"); + } + } + #[test] fn paint_copies_past_the_budget_are_refused_for_the_whole_group() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index add1f3b79..6604c99a2 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -285,6 +285,22 @@ fn revolve_on_a_fill_is_checked_in_that_fills_paint() { assert!(err.contains("simplify the gradient"), "{err}"); } +/// A hidden Revolve still counts as the object's one Revolve: a second one is refused, and the +/// hidden one can be shown again over the geometry effects after it. +#[test] +fn a_second_revolve_is_refused_over_a_hidden_one() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": false})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [id.0], "item": null})).unwrap(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("one Revolve effect"), "{err}"); + let ids: Vec<_> = node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect(); + assert_eq!(ids, ["threeD.revolve", "distort.roughen"]); + s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": true})).unwrap(); + assert!(node(&s, id).appearance.effects[0].visible); +} + /// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps /// each member's paint, follows an edited member, expands and undoes as one step. #[test] From fd0be1aca044d5e1fccec374f3fe6bd71c3343a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:57:27 -0800 Subject: [PATCH 25/48] 3D: a profile's empty contours count toward its point limit revolve_curve_samples counted a profile's contours but never compared the count with the limit, so a profile made only of empty or one-anchor subpaths passed, although each contour takes one profile point. HoleSearch::new then built one entry for each subpath without a limit, and mesh export skips revolve_curve_samples when curve_samples is given. Both now refuse a profile with more than 16,384 contours as too complex. --- crates/three-d/src/lib.rs | 8 ++++++-- crates/three-d/src/tests/profile_limits.rs | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 479481c2a..fa22c10fb 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -480,10 +480,10 @@ pub fn revolve_curve_samples<'a>(paths: impl IntoIterator<Item = &'a PathData>, let (mut anchors, mut contours, mut lines, mut curves, mut caps) = (0usize, 0usize, 0usize, 0usize, 0usize); for sp in paths.into_iter().flat_map(|p| &p.subpaths) { anchors = anchors.saturating_add(sp.anchors.len()); - if anchors > MAX_PROFILE_POINTS { + contours += 1; + if anchors > MAX_PROFILE_POINTS || contours > MAX_PROFILE_POINTS { return Err(TOO_COMPLEX_PROFILE.into()); } - contours += 1; let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; for i in 0..segments { if sp.segment_is_line(i) { @@ -551,6 +551,10 @@ struct HoleSearch { impl HoleSearch { fn new(path: &PathData, rule: FillRule) -> Result<Self, String> { + // Each contour takes a profile point, as in `revolve_curve_samples`. + if path.subpaths.len() > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } let contours: Vec<(BezPath, Rect, Option<i32>)> = path .subpaths .iter() diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs index 5eeb10f2a..148cf4fcc 100644 --- a/crates/three-d/src/tests/profile_limits.rs +++ b/crates/three-d/src/tests/profile_limits.rs @@ -68,6 +68,17 @@ fn too_many_anchors_are_refused_as_a_profile() { assert_eq!(refused, TOO_COMPLEX_PROFILE); } +#[test] +fn every_contour_takes_a_point_even_an_empty_one() { + // Contours of one anchor and of none, with no segment between them. + let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(100.0, i as f64))] }, false); + let path = PathData::new((0..=MAX_PROFILE_POINTS).map(lone).collect()); + assert_eq!(revolve_curve_samples([&path], Revolve::default()), Err(TOO_COMPLEX_PROFILE.to_string())); + assert_eq!(hole_segments(&path, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string()), "before any contour is built"); + let given = Revolve { curve_samples: Some(CURVE_SAMPLES), ..Revolve::default() }; + assert_eq!(revolve_mesh(&path, FillRule::NonZero, path.bounds().unwrap(), given).map(|_| ()), Err(TOO_COMPLEX_PROFILE.to_string())); +} + #[test] fn a_profile_at_the_point_limit_is_taken_and_one_past_it_is_refused() { let straight = |anchors: usize| { From 9d01c0e7b5bfeed18e2fbdd37f7d89dc4cf92aff Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:59:29 -0800 Subject: [PATCH 26/48] MCP: test that list_commands gives input schemas over stdio --- crates/mcp/src/tests/protocol.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/mcp/src/tests/protocol.rs b/crates/mcp/src/tests/protocol.rs index 1fe41c3e5..a3c79aae7 100644 --- a/crates/mcp/src/tests/protocol.rs +++ b/crates/mcp/src/tests/protocol.rs @@ -261,3 +261,20 @@ fn log_records_go_out_as_notifications_once_asked_for() { assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}"); result(&mut s, 4, "logging/setLevel", json!({"level": null})); } + +/// `list_commands` gives each command's input schema over stdin and stdout, as a client reads it. +#[test] +fn list_commands_over_stdio_carries_input_schemas() { + let mut s = server(); + let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_commands", "arguments": {"filter": "view.saved"}}}); + let mut out = Vec::new(); + s.serve((call.to_string() + "\n").as_bytes(), &mut out).unwrap(); + let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let reply = lines.iter().find(|l| l["id"] == 1).cloned().unwrap_or(Value::Null); + assert_eq!(reply["result"]["isError"], false, "{reply}"); + let listed: Value = serde_json::from_str(reply["result"]["content"][0]["text"].as_str().unwrap_or("null")).unwrap_or(Value::Null); + let commands = listed["commands"].as_array().cloned().unwrap_or_default(); + assert!(commands.iter().filter(|c| c.get("inputSchema").is_some()).count() >= 3, "{listed}"); + let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").cloned().unwrap_or(Value::Null); + assert_eq!(saved_list["inputSchema"]["type"], "object", "{listed}"); +} From 62ed510c1e7daac3b37c38c3dbb655ead712c702 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 20:30:33 -0800 Subject: [PATCH 27/48] Scripts: a failed run puts the Caps Lock state back view.capsLock joins the state a run puts back when it fails or is stopped. A finished run keeps the state it set, and keeps the state the app reported during the run when the script did not change it. The pointer over the canvas is refreshed when a run's end changes Caps Lock. --- crates/engine/src/script/mod.rs | 13 ++++++++++++- crates/engine/src/tests/script_run.rs | 8 ++++++++ crates/ui-egui/src/canvas/tests.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/script_runs.rs | 4 ++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index ee351d298..643f7ea31 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -170,14 +170,17 @@ struct World { journal: Journal, /// The preferences when the run started. start_prefs: crate::Prefs, + /// The Caps Lock state when the run started. + start_caps_lock: bool, } /// The choices outside the documents that a script's commands can change: the preferences, the /// global edit options, the recent URLs, the chosen gradient stop, freeform point and appearance -/// item, the Eyedropper's sample and the envelope defaults. +/// item, the Eyedropper's sample, the envelope defaults and the Caps Lock state. #[derive(Clone)] struct Choices { prefs: crate::Prefs, + caps_lock: bool, eyedropper_sample: Option<Arc<vectorcraft_doc::Node>>, global_edit_options: cmd::globaledit::Options, recent_urls: Vec<String>, @@ -191,6 +194,7 @@ impl Choices { fn of(s: &Session) -> Self { Self { prefs: s.prefs.clone(), + caps_lock: s.caps_lock, eyedropper_sample: s.eyedropper_sample.clone(), global_edit_options: s.global_edit_options.clone(), recent_urls: s.recent_urls.clone(), @@ -203,6 +207,7 @@ impl Choices { fn put(self, s: &mut Session) { s.prefs = self.prefs; + s.caps_lock = self.caps_lock; s.eyedropper_sample = self.eyedropper_sample; s.global_edit_options = self.global_edit_options; s.recent_urls = self.recent_urls; @@ -215,6 +220,7 @@ impl Choices { fn swap(&mut self, s: &mut Session) { use std::mem::swap; swap(&mut self.prefs, &mut s.prefs); + swap(&mut self.caps_lock, &mut s.caps_lock); swap(&mut self.eyedropper_sample, &mut s.eyedropper_sample); swap(&mut self.global_edit_options, &mut s.global_edit_options); swap(&mut self.recent_urls, &mut s.recent_urls); @@ -267,6 +273,7 @@ impl World { choices: Choices::of(s), journal: Journal::default(), start_prefs: s.prefs.clone(), + start_caps_lock: s.caps_lock, } } @@ -933,6 +940,10 @@ impl Session { // The working copy goes into the session; `job.world` holds the state before the run. job.world.swap(self); self.prefs = merged_prefs(&job.world.start_prefs, &self.prefs, &job.world.choices.prefs); + // The Caps Lock state takes the run's value only when the run changed it. + if self.caps_lock == job.world.start_caps_lock { + self.caps_lock = job.world.choices.caps_lock; + } note_ids(self); raise_ids(self); let start = std::mem::take(&mut job.world.docs); diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 566e36145..bc3cc67a9 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -279,6 +279,12 @@ fn a_failed_run_puts_back_the_choices_its_commands_made() { {command: 'no.such', params: {}}]}); } catch (e) {}"; run(&mut s, source).unwrap(); assert_eq!(s.global_edit_options, before, "the failed command's choice went back"); + + assert!(!s.caps_lock); + assert!(run(&mut s, "vc.run('view.capsLock', {on: true}); throw new Error('x');").is_err()); + assert!(!s.caps_lock, "the Caps Lock state went back"); + run(&mut s, "vc.run('view.capsLock', {on: true});").unwrap(); + assert!(s.caps_lock, "a finished run keeps it"); } #[test] @@ -296,8 +302,10 @@ fn a_finished_run_keeps_its_preferences_and_the_ones_the_app_changed_meanwhile() paused(&mut s, &format!("vc.run('appearance.setNewArtBasic', {{on: {}}}); for (var i = 0; i < 2000000; i++) {{}}", !basic)); assert_eq!(s.prefs.new_art_basic, basic, "between slices the app sees the preferences before the run"); s.prefs.keyboard_increment = increment + 1.0; + s.execute("view.capsLock", &json!({"on": true})).unwrap(); while s.script_pump().is_none() {} assert_eq!((s.prefs.new_art_basic, s.prefs.keyboard_increment), (!basic, increment + 1.0)); + assert!(s.caps_lock, "the Caps Lock state the app reported during the run stays"); } #[test] diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 42778c729..e085baebd 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -210,6 +210,27 @@ fn command_cursor_settings_refresh_hover_cursor_while_view_moves() { assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Crosshair)); } +/// A script that changes Caps Lock refreshes the pointer when its run ends. +#[test] +fn a_script_run_refreshes_hover_cursor_while_view_moves() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.session.select_tool("pen", vectorcraft_engine::ViewInfo::default()).unwrap(); + app.hover_doc = Some(Point::new(200.0, 150.0)); + let mods = Mods::default(); + let view = vectorcraft_engine::ViewInfo::default(); + assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Pen)); + app.run("script.run", json!({"source": "vc.run('view.capsLock', {on: true});"})).unwrap(); + for _ in 0..1_000 { + if !app.session.script_running() { + break; + } + crate::script_runs::poll(&mut app); + } + assert!(app.session.caps_lock); + assert_eq!(hover_cursor(&mut app, mods, view, false), Some(Cursor::Crosshair)); +} + /// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one /// reads x where the canvas has it. #[test] diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs index d88c0bf1b..042e24cd3 100644 --- a/crates/ui-egui/src/script_runs.rs +++ b/crates/ui-egui/src/script_runs.rs @@ -21,7 +21,11 @@ pub fn poll(app: &mut VectorcraftApp) { return; } let name = app.session.script_name().unwrap_or_default(); + let cursor_settings = (app.session.caps_lock, app.session.prefs.use_precise_cursors); let Some(result) = app.session.script_pump() else { return }; + if cursor_settings != (app.session.caps_lock, app.session.prefs.use_precise_cursors) { + app.canvas.hover_cursor = None; + } if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::GROWTH_KIND) { app.ui.dialog = None; } From af2ee68bf3f61ed5a2e75c5eeffe78a79667ca9c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 20:49:13 -0800 Subject: [PATCH 28/48] Revolve: a group's faces keep one back-to-front order when a member is transparent When any member of a revolved group had an opacity below 100% or a blending mode, every member's surface was drawn whole in member order, so the opaque members lost their shared depth sort and a transparent member drew entirely over or under the others. Every member's faces are now sorted into one order. Opaque faces go in that order on their own. A transparent member's faces share one group with its opacity and blending mode, and its scopes group them further, unless a face of another member that overlaps them on the page lies between them in the order; only there is the member split. Faces overlap when the convex hulls of their outlines come within the 0.75 pt seam stroke of each other. Faces that paint nothing never split a member. Past 4,194,304 overlap tests on one group, each further face of such a member is drawn as a part of its own. Docs: docs/revolve.md. --- crates/effects/src/revolve.rs | 330 ++++++++++++++++++++++++++++++---- docs/revolve.md | 5 + 2 files changed, 299 insertions(+), 36 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 99a6e053e..1a491daa5 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{PathData, Rect}; +use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; @@ -18,6 +18,13 @@ pub const REVOLVE: &str = "threeD.revolve"; /// thousands of faces took gigabytes; past this the source path is drawn. const MAX_PAINT_COPIES: usize = 256 << 20; +/// The most face and box tests a group's transparent members' faces take together to rejoin their +/// members' last runs; past them each starts a run of its own. +const MAX_OVERLAP_TESTS: usize = 1 << 22; + +/// The width of the stroke around each face that covers the seams between faces. +const SEAM_WIDTH: f64 = 0.75; + /// About the bytes one copy of `paint` takes. fn paint_bytes(paint: &Paint) -> usize { use std::mem::size_of; @@ -265,7 +272,7 @@ fn surface( .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75); + let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH); // Acute face corners must stay within the overlap width. Miter joins // amplify this seam stroke into visible spikes along the silhouette. edge.join = LineJoin::Round; @@ -431,11 +438,11 @@ fn art(n: &Node, expand: bool) -> Option<Node> { Some(art) } -/// The members' `surfaces` in order, from the [`Scope`] at `level` of each one's `Profile::scopes` -/// inward: the surfaces of consecutive members that share a scope go in one group with its opacity -/// and blending mode, so each applies once, to the members it holds together. -fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec<Arc<Node>> { - fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option<Scope>, level: usize, out: &mut Vec<Arc<Node>>) { +/// The members' `surfaces`, or runs of their faces, in order, from the [`Scope`] at `level` of each +/// one's `Profile::scopes` inward: consecutive surfaces that share a scope go in one group with its +/// opacity and blending mode, so each applies once, to the faces it holds together. +fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Arc<Node>)>, level: usize) -> Vec<Arc<Node>> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc<Node>)>, scope: Option<Scope>, level: usize, out: &mut Vec<Arc<Node>>) { if let Some(scope) = scope { let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); group.opacity = scope.opacity; @@ -455,15 +462,104 @@ fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec<Arc< if scope.is_some() { run.push((scopes, s)); } else { - out.push(Arc::new(s)); + out.push(s); } } close(id, &mut run, scope, level, &mut out); out } +/// What a face can paint, to tell whether two faces overlap: the convex hull of the points and +/// control points of its outline, which holds the outline, and the hull's box grown by half the +/// seam stroke. +struct Coverage { + bounds: Rect, + hull: Vec<Point>, +} + +impl Coverage { + /// `None` for a face with no outline and no box. + fn of(face: &Node) -> Option<Coverage> { + let points: Vec<Point> = match face.path_data() { + Some(path) => path + .to_bezpath() + .elements() + .iter() + .flat_map(|e| match *e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![p], + PathEl::QuadTo(a, b) => vec![a, b], + PathEl::CurveTo(a, b, c) => vec![a, b, c], + PathEl::ClosePath => vec![], + }) + .collect(), + None => { + let b = face.visual_bounds()?; + vec![Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)] + } + }; + let hull = convex_hull(points); + let first = *hull.first()?; + let reach = SEAM_WIDTH / 2.0; + let bounds = hull.iter().fold(Rect::from_points(first, first), |b, p| b.union_pt(*p)).inflate(reach, reach); + Some(Coverage { bounds, hull }) + } + + /// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the + /// separating axes of their edges and of the page's axes: `true` whenever they overlap, and + /// sometimes near a corner when they don't. + fn meets(&self, other: &Coverage) -> bool { + fn normals(hull: &[Point]) -> impl Iterator<Item = Vec2> + '_ { + (0..hull.len()).filter_map(|i| { + let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?); + Some(Vec2::new(a.y - b.y, b.x - a.x)) + }) + } + let axes = [Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)].into_iter().chain(normals(&self.hull)).chain(normals(&other.hull)); + let span = |hull: &[Point], axis: Vec2| { + hull.iter().map(|p| p.to_vec2().dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))) + }; + axes.filter(|axis| axis.hypot() > 0.0).all(|axis| { + let ((a0, a1), (b0, b1)) = (span(&self.hull, axis), span(&other.hull, axis)); + let gap = SEAM_WIDTH * axis.hypot(); + b0 - a1 < gap && a0 - b1 < gap + }) + } +} + +/// The convex hull of `points`, counterclockwise in a y-up frame, without repeated points. +fn convex_hull(mut points: Vec<Point>) -> Vec<Point> { + points.retain(|p| p.is_finite()); + points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + points.dedup(); + if points.len() < 3 { + return points; + } + // One side of the hull from the first point to the last, without the last. + fn side<'a>(points: impl Iterator<Item = &'a Point>) -> Vec<Point> { + let mut chain: Vec<Point> = vec![]; + for &p in points { + while let [.., o, a] = chain[..] + && (a - o).cross(p - o) <= 0.0 + { + chain.pop(); + } + chain.push(p); + } + chain.pop(); + chain + } + let mut hull = side(points.iter()); + hull.extend(side(points.iter().rev())); + hull +} + +/// Do boxes `a` and `b` overlap with some area? +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + /// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, -/// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when +/// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. fn group_art(n: &Node, expand: bool) -> Option<Node> { let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; @@ -490,18 +586,86 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { return None; } let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); - let children = if opaque && plain { + let children = if !plain { + scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0) + } else { + let count = surfaces.len(); let mut faces = vec![]; - for (depths, s, _) in surfaces { + for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() { + let (opacity, blend) = (s.opacity, s.blend); if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children)); + faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face))); } } // A stable sort: faces at the same depth keep the members' order. faces.sort_by(|a, b| a.0.total_cmp(&b.0)); - vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] - } else { - scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, s)).collect(), 0) + if opaque { + vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))] + } else { + // An opaque member's face goes in the order on its own. The faces of a member with + // transparency share a run, one group with the opacity and blending mode of its fill or + // stroke, and its scopes group runs further. Such a face joins its member's last run + // unless a face drawn after that run overlaps it: only then does the member split, + // where one group can't be both behind and in front of another face. + struct Run<'a> { + member: Option<usize>, + scopes: &'a [Scope], + bounds: Rect, + faces: Vec<Arc<Node>>, + cover: Vec<Coverage>, + opacity: f32, + blend: vectorcraft_doc::color::BlendMode, + } + let mut runs: Vec<Run> = vec![]; + let mut last_run = vec![None; count]; + // Past this many tests a face starts a run of its own, which never draws out of order. + let mut budget = MAX_OVERLAP_TESTS; + for (_, member, opacity, blend, scopes, face) in faces { + let paints = opacity > 0.0 && scopes.iter().all(|s| s.opacity > 0.0); + let Some(cover) = Coverage::of(&face).filter(|_| paints) else { + // A face that paints nothing goes in the order on its own and overlaps nothing. + runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend }); + continue; + }; + if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() { + runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); + continue; + } + let mut clear = |r: &Run| { + budget = budget.saturating_sub(1); + if !overlaps(r.bounds, cover.bounds) { + return budget > 0; + } + budget = budget.saturating_sub(r.cover.len()); + budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover)) + }; + let joins = + last_run.get(member).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear))); + match joins.and_then(|i| runs.get_mut(i)) { + Some(run) => { + run.bounds = run.bounds.union(cover.bounds); + run.faces.push(face); + run.cover.push(cover); + } + None => { + if let Some(last) = last_run.get_mut(member) { + *last = Some(runs.len()); + } + runs.push(Run { member: Some(member), scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); + } + } + } + let runs = runs.into_iter().filter_map(|mut r| { + if r.member.is_none() { + return r.faces.pop().map(|face| (r.scopes, face)); + } + let mut group = Node::group(n.id, r.faces); + group.opacity = r.opacity; + group.blend = r.blend; + Some((r.scopes, Arc::new(group))) + }); + scoped(n.id, runs.collect(), 0) + } }; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -712,18 +876,76 @@ mod tests { } #[test] - fn opaque_members_share_one_back_to_front_order_and_others_keep_their_own_surfaces() { + fn opaque_members_share_one_back_to_front_order() { let g = group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)]); let art = revolve_art(&g).unwrap(); assert_eq!(art.children().unwrap().len(), 1, "one order for every face"); assert!(art.children().unwrap()[0].children().unwrap().len() > 16); - let mut faded = (*member(2, 50.0)).clone(); + } + + /// The opacities and blending modes of the groups a face is drawn in, outermost first, leaving + /// out groups with neither. + type Chain = Vec<(f32, vectorcraft_doc::color::BlendMode)>; + + /// The faces of `art` in drawing order, each with its [`Chain`]. + fn faces_in_order(art: &Node) -> Vec<(Node, Chain)> { + fn visit(n: &Node, mut scopes: Chain, out: &mut Vec<(Node, Chain)>) { + match n.children() { + Some(children) => { + if n.opacity != 1.0 || n.blend != Default::default() { + scopes.push((n.opacity, n.blend)); + } + for c in children { + visit(c, scopes.clone(), out); + } + } + None => out.push((n.clone(), scopes)), + } + } + let mut out = vec![]; + visit(art, vec![], &mut out); + out + } + + #[test] + fn a_transparent_member_leaves_every_face_in_one_back_to_front_order() { + let faces_of = |members| faces_in_order(&revolve_art(&group_with(json!({"segments":16}), members)).unwrap()); + let alone = faces_of(vec![member(1, 0.0), member(2, 50.0)]); + let mut faded = (*member(3, 20.0)).clone(); faded.opacity = 0.5; - let g = group_with(json!({"segments":16}), vec![member(1, 0.0), Arc::new(faded)]); - let art = revolve_art(&g).unwrap(); - let surfaces = art.children().unwrap(); - assert_eq!(surfaces.len(), 2, "a transparent member stays a surface of its own"); - assert_eq!(surfaces[1].opacity, 0.5); + let faces = faces_of(vec![member(1, 0.0), member(2, 50.0), Arc::new(faded)]); + let opaque: Vec<_> = faces.iter().filter(|(_, s)| s.is_empty()).map(|(f, _)| f).collect(); + assert_eq!(opaque, alone.iter().map(|(f, _)| f).collect::<Vec<_>>(), "the opaque members' faces keep their shared order"); + assert!(faces.iter().all(|(_, s)| s.is_empty() || s == &[(0.5, Default::default())]), "the member's opacity applies once to each face"); + let first = faces.iter().position(|(_, s)| !s.is_empty()).unwrap(); + let last = faces.iter().rposition(|(_, s)| !s.is_empty()).unwrap(); + assert!(first > 0 && last + 1 < faces.len(), "the ring between the others draws in front of the back ones and behind the front ones"); + let groups = |art: &Node| { + let mut count = 0; + art.walk(&mut |n| count += usize::from(n.children().is_some() && n.opacity == 0.5)); + count + }; + // Rings one above the other: their faces alternate in the order but none overlaps the + // other ring, so each ring's opacity applies once, to all its faces. + let faded = |id, y: f64| { + let mut n = (*member(id, 40.0)).clone(); + n.opacity = 0.5; + if let NodeKind::Path { path, .. } = &mut n.kind { + *path = PathData::from_bezpath(&(Affine::translate((0.0, y)) * path.to_bezpath())); + } + Arc::new(n) + }; + let art = revolve_art(&group_with(json!({"segments":16}), vec![faded(1, 0.0), faded(2, 200.0)])).unwrap(); + assert!(faces_in_order(&art).iter().all(|(_, s)| s == &[(0.5, Default::default())])); + assert_eq!(groups(&art), 2, "one group for each ring"); + // A tall cylinder inside both that paints nothing splits neither. + let mut hidden = (*member(3, 0.0)).clone(); + hidden.opacity = 0.0; + if let NodeKind::Path { path, .. } = &mut hidden.kind { + *path = PathData::from_bezpath(&(Affine::translate((0.0, -20.0)) * Affine::scale_non_uniform(1.0, 7.0) * path.to_bezpath())); + } + let art = revolve_art(&group_with(json!({"segments":16}), vec![faded(1, 0.0), faded(2, 200.0), Arc::new(hidden)])).unwrap(); + assert_eq!(groups(&art), 2, "one group for each faded ring"); } fn star(id: u64) -> Arc<Node> { @@ -741,6 +963,33 @@ mod tests { )) } + #[test] + fn faces_overlap_by_their_painted_shapes_and_seams_not_only_their_boxes() { + let face = |points: &[(f64, f64)]| { + let mut b = BezPath::new(); + b.move_to(points[0]); + for &p in &points[1..] { + b.line_to(p); + } + b.close_path(); + Coverage::of(&Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::default())).unwrap() + }; + let a = face(&[(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)]); + assert_eq!(a.hull.len(), 3); + assert_eq!( + face(&[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.0, 5.0), (0.0, 10.0)]).hull.len(), + 4, + "a point on an edge or inside is left out" + ); + // Across the diagonal 2 / sqrt(2) apart: their boxes overlap, their shapes and seams don't. + let b = face(&[(10.0, 10.0), (10.0, 2.0), (2.0, 10.0)]); + assert!(overlaps(a.bounds, b.bounds) && !a.meets(&b) && !b.meets(&a)); + // 1 / sqrt(2) apart, within the two half seam strokes. + let c = face(&[(10.0, 10.0), (10.0, 1.0), (1.0, 10.0)]); + assert!(a.meets(&c) && c.meets(&a)); + assert!(a.meets(&a), "a face meets itself"); + } + #[test] fn a_groups_members_share_revolves_limits() { let alone = group_with(json!({}), vec![star(1)]); @@ -763,28 +1012,37 @@ mod tests { let screened = blend(Arc::new(Node::group(NodeId(5), vec![member(3, 40.0), inner])), BlendMode::Screen); let group = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply), member(2, 20.0), screened]); let art = revolve_art(&group).expect("revolved"); - let modes = |n: &Node| match &n.kind { - NodeKind::Group { children, .. } => children.iter().map(|c| c.blend).collect::<Vec<_>>(), - _ => panic!("a group"), + let faces = faces_in_order(&art); + let modes = |s: &[(f32, BlendMode)]| s.iter().map(|(_, b)| *b).collect::<Vec<_>>(); + let chains = [vec![BlendMode::Multiply], vec![], vec![BlendMode::Screen], vec![BlendMode::Screen, BlendMode::Multiply]]; + for chain in &chains { + assert!(faces.iter().any(|(_, s)| &modes(s) == chain), "faces drawn in {chain:?}"); + } + assert!(faces.iter().all(|(_, s)| chains.contains(&modes(s))), "each mode applies once, to the member or group that has it"); + // Faces of the screened group's members that come together in the drawing order share one Screen group. + let runs = |faces: &[(Node, Chain)], inside: &dyn Fn(&Chain) -> bool| { + faces.windows(2).filter(|w| inside(&w[1].1) && !inside(&w[0].1)).count() + usize::from(faces.first().is_some_and(|f| inside(&f.1))) }; - assert_eq!(modes(&art), [BlendMode::Multiply, BlendMode::Normal, BlendMode::Screen], "a surface or group for each member"); - let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; - let screened = &children[2]; - assert_eq!(modes(screened), [BlendMode::Normal, BlendMode::Multiply], "the screened group's members, together, in their own modes"); - let NodeKind::Group { children: inner, .. } = &screened.kind else { panic!("a group") }; - assert_eq!(modes(&inner[1]).len(), 1, "the multiplied member's surface"); + let groups = |art: &Node, pick: &dyn Fn(&Node) -> bool| { + let mut count = 0; + art.walk(&mut |n| count += usize::from(n.children().is_some() && pick(n))); + count + }; + let screened = |s: &Chain| s.first().is_some_and(|(_, b)| *b == BlendMode::Screen); + assert_eq!(groups(&art, &|n| n.blend == BlendMode::Screen), runs(&faces, &screened)); let plain = group_with(json!({}), vec![member(1, 0.0)]); assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed"); let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]); assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face"); - // A nested group's opacity applies once, to its members' surfaces together. + // A nested group's opacity applies once, to its members' faces that come together. let mut faded = Node::group(NodeId(6), vec![member(3, 40.0), member(4, 45.0)]); faded.opacity = 0.5; let art = revolve_art(&group_with(json!({}), vec![member(1, 0.0), Arc::new(faded)])).expect("revolved"); - let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; - let opacities = |n: &Node| n.children().unwrap().iter().map(|c| c.opacity).collect::<Vec<_>>(); - assert_eq!(children.iter().map(|c| c.opacity).collect::<Vec<_>>(), [1.0, 0.5]); - assert_eq!(opacities(&children[1]), [1.0, 1.0], "the faded group's members, together"); + let faces = faces_in_order(&art); + assert!(faces.iter().all(|(_, s)| s.is_empty() || s == &[(0.5, BlendMode::Normal)]), "the faded group's opacity applies once"); + let half = |s: &Chain| !s.is_empty(); + assert!(faces.iter().any(|(_, s)| half(s)) && faces.iter().any(|(_, s)| !half(s))); + assert_eq!(groups(&art, &|n| n.opacity == 0.5), runs(&faces, &half), "the faded group's members, together"); } #[test] diff --git a/docs/revolve.md b/docs/revolve.md index fd0f36f16..6ecd4957e 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -6,6 +6,11 @@ Select a path, compound path, type, or a group or layer of them, then choose **E On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. +The faces of all members draw in one back-to-front order. The opacity and blending mode of a member, or of a group +inside the group, apply once to all its faces. Where a face of another member lies between its faces in that order +and overlaps them on the page, its faces are split there, and the opacity and blending mode apply once to each part. +Two faces overlap when the convex hulls of their outlines come within the 0.75 pt seam stroke of each other. After +4,194,304 overlap tests on one group, each further face of such a member is drawn as a part of its own. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. From e33906976d91ad615625831633d304264a0e2aa7 Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Tue, 10 Nov 2026 10:00:03 +0500 Subject: [PATCH 29/48] Spelling right-click: only the topmost type's word; dictionary tests one at a time (part of #4936) --- crates/engine/src/tests/spelling.rs | 17 ++++++++ crates/ui-egui/src/canvas/tool_overlays.rs | 49 ++++++++++++---------- crates/ui-egui/src/tests/contextmenu.rs | 22 ++++++++++ 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs index 0dfc9fab5..eca38d8e6 100644 --- a/crates/engine/src/tests/spelling.rs +++ b/crates/engine/src/tests/spelling.rs @@ -6,6 +6,14 @@ use serde_json::{Value, json}; use crate::cmd::spelling::MAX_DICTIONARY_FILE_BYTES; use crate::*; +/// Held by each test that changes the custom dictionary, which the whole process shares: a save +/// writes every word in it to the test's file and a reload takes the file's words back, so another +/// test's word could come back after that test deleted it. +fn dictionary_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// A session with a new 400 × 400 document. fn session() -> Session { let mut s = Session::new(); @@ -115,6 +123,7 @@ fn marked(s: &mut Session) -> Vec<String> { #[test] fn added_words_are_right_in_every_check_until_deleted() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); text(&mut s, 50.0, "drawing Zqvwkx tools"); assert_eq!(marked(&mut s), ["Zqvwkx"]); @@ -137,6 +146,7 @@ fn added_words_are_right_in_every_check_until_deleted() { #[test] fn a_changed_word_replaces_the_old_one() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); text(&mut s, 50.0, "xkvzqa xkvzqb"); run(&mut s, "text.spelling.addWord", json!({"word": "xkvzqa"})); @@ -150,6 +160,7 @@ fn a_changed_word_replaces_the_old_one() { #[test] fn the_custom_dictionary_file_is_read_and_kept_up_to_date() { + let _one_at_a_time = dictionary_lock(); let dir = vectorcraft_testkit::temp_dir("spelling-dictionary"); let file = dir.join("Custom Dictionary.txt"); std::fs::write(&file, "kqzvwx\nnot one word\n\n").unwrap(); @@ -170,6 +181,7 @@ fn the_custom_dictionary_file_is_read_and_kept_up_to_date() { #[test] fn ignore_all_skips_a_word_in_the_active_document_only() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); text(&mut s, 50.0, "Wqzxkv drawing wqzxkv vqzxkw"); run(&mut s, "text.spelling.ignoreAll", json!({"word": "WQZXKV"})); @@ -183,6 +195,7 @@ fn ignore_all_skips_a_word_in_the_active_document_only() { #[test] fn custom_dictionary_commands_refuse_what_isnt_one_word() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); let long = "q".repeat(65); for (id, p) in [ @@ -210,6 +223,7 @@ fn in_dictionary(s: &mut Session, word: &str) -> bool { #[test] fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() { + let _one_at_a_time = dictionary_lock(); let dir = vectorcraft_testkit::temp_dir("spelling-unreadable"); // A folder where the file should be can't be read as one; a file past what craft-proof reads // would lose its other words if written back. @@ -231,6 +245,7 @@ fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() { #[test] fn a_change_that_cant_be_saved_is_taken_back() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); run(&mut s, "text.spelling.addWord", json!({"word": "wkqzvx"})); let dir = vectorcraft_testkit::temp_dir("spelling-rollback"); @@ -248,6 +263,7 @@ fn a_change_that_cant_be_saved_is_taken_back() { #[cfg(unix)] #[test] fn a_word_another_window_saved_stays_when_this_save_fails() { + let _one_at_a_time = dictionary_lock(); use std::os::unix::fs::PermissionsExt; let dir = vectorcraft_testkit::temp_dir("spelling-readonly"); let file = dir.join("Custom Dictionary.txt"); @@ -296,6 +312,7 @@ fn misspellings_of_one_object_are_what_the_check_lists() { /// what Auto Spell Check found before is checked again. #[test] fn custom_dictionary_changes_move_its_generation_on() { + let _one_at_a_time = dictionary_lock(); use crate::cmd::spelling::dictionary_generation; let mut s = session(); let mut last = dictionary_generation(); diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index 5e23522f7..0151b3558 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -166,41 +166,48 @@ fn auto_spell_checks(n: &Node) -> bool { !shown(&n.appearance.effects) && !n.appearance.items.iter().any(|i| shown(i.effects())) } -/// The word with Auto Spell Check's red line at `p` (document space), on top of the others: in -/// the topmost visible, unlocked type object whose unknown word's glyphs `p` is on, with what its -/// language's dictionary suggests instead. For the context menu's suggestions, Ignore All and Add +/// The word with Auto Spell Check's red line at `p` (document space): in the topmost visible type +/// object whose glyphs `p` is on, when that object isn't locked and Auto Spell Check marks it and +/// its word at `p` is one it underlines, with what its language's dictionary suggests instead. For the context menu's suggestions, Ignore All and Add /// to Dictionary ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)). pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option<crate::state::ContextSpelling> { let st = app.session.active()?; - let mut hit = None; - fn walk(nodes: &[std::sync::Arc<Node>], f: &mut impl FnMut(&Node)) { - for n in nodes.iter().filter(|n| n.visible && !n.locked) { - f(n); + // The topmost type whose glyphs `p` is on, and whether it or a layer or group holding it is + // locked: a word under other type isn't the one right-clicked, whether that type's word there + // is right or not. + let mut top: Option<(&Node, bool)> = None; + fn walk<'a>(nodes: &'a [std::sync::Arc<Node>], locked: bool, f: &mut impl FnMut(&'a Node, bool)) { + for n in nodes.iter().filter(|n| n.visible) { + let locked = locked || n.locked; + f(n, locked); if let Some(children) = n.children() { - walk(children, f); + walk(children, locked, f); } } } // Paint order: a later hit is on top. - walk(&st.doc.layers, &mut |n| { + walk(&st.doc.layers, false, &mut |n, locked| { let NodeKind::Text(tx) = &n.kind else { return }; - if !auto_spell_checks(n) || n.geometric_bounds().is_none_or(|b| !b.contains(p)) { - return; - } - let (words, _) = vectorcraft_engine::cmd::spelling::misspellings(tx, &st.spelling_ignored, MAX_UNDERLINED); - if words.is_empty() { + if n.geometric_bounds().is_none_or(|b| !b.contains(p)) { return; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); - for w in words { - let on = vectorcraft_text::selection_quads(&lay, w.range.start, w.range.end).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)); - if on { - hit = Some((n.id, w)); - } + let on = vectorcraft_text::selection_quads(&lay, 0, tx.plain_text().len()).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)); + if on { + top = Some((n, locked)); } }); - let (id, w) = hit?; - let Some(NodeKind::Text(tx)) = st.doc.node(id).map(|n| &n.kind) else { return None }; + let (n, locked) = top?; + let NodeKind::Text(tx) = &n.kind else { return None }; + if locked || !auto_spell_checks(n) { + return None; + } + let (words, _) = vectorcraft_engine::cmd::spelling::misspellings(tx, &st.spelling_ignored, MAX_UNDERLINED); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); + let w = words + .into_iter() + .find(|w| vectorcraft_text::selection_quads(&lay, w.range.start, w.range.end).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)))?; + let id = n.id; let word = tx.plain_text().get(w.range.clone())?.to_string(); let uid = st.uid; let suggestions = app diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index 0812f410e..830da939d 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -506,3 +506,25 @@ fn no_spelling_items_off_a_misspelled_word_or_with_auto_spell_check_off() { let texts = click(&mut app, &ctx, p, PointerButton::Secondary); assert!(!has(&texts, "Ignore All"), "off: {texts:?}"); } + +/// A right word in type on top of an underlined word in other type: the right-click is on the +/// top type, so it lists no spelling items for the word under it. +#[test] +fn a_word_under_other_type_is_not_the_one_right_clicked() { + let mut app = app(); + let under = spelled(&mut app, "Qzvxtrwk"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = word_at(&app, under, 0, 8); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(has(&texts, "Ignore All"), "alone, the word is offered: {texts:?}"); + frame( + &mut app, + &ctx, + vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + app.run("text.create", json!({"x": 20, "y": 60, "text": "the cat sat on the mat", "size": 24})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "under right type: {texts:?}"); +} From a1949095c61e21d2ea73eaca73a5b598eced6d98 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 21:07:55 -0800 Subject: [PATCH 30/48] Type: an option fits a text object's bounds to its glyph outlines Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default) is in Preferences > Type > Glyph Bounds and the Transform panel menu. With it on, the bounding box, the Transform, Control bar and Properties X, Y, W and H, `object.setBounds`, the transform tools' reference point and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size. - The engine caches each type object's ink in text space with its layout bounds; the cache is not saved. - Use Preview Bounds adds the characters' strokes, and type with geometry effects is measured as drawn. Skewed type is measured by its outlines. - Type that draws nothing, area type, type on a path and type inside a selected group are measured as before. Align and Distribute keep following Align to Glyph Bounds. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md, object.setBounds params text. --- Cargo.lock | 1 + crates/doc/src/blend.rs | 1 + crates/doc/src/document_guides.rs | 1 + crates/doc/src/inline.rs | 1 + crates/doc/src/live/envelope.rs | 1 + crates/doc/src/text.rs | 5 + crates/doc/src/text/object.rs | 8 + crates/effects/src/preview.rs | 107 +++++++ crates/engine/Cargo.toml | 2 + .../engine/src/cmd/addons/cleanup/checks.rs | 1 + crates/engine/src/cmd/object/align.rs | 17 +- crates/engine/src/cmd/object/bounds.rs | 35 ++- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/object/transform.rs | 2 +- crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/cmd/textedit.rs | 2 + crates/engine/src/cmd/typecmd.rs | 2 + crates/engine/src/prefs.rs | 6 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/typeglyphbounds.rs | 297 ++++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/context.rs | 8 + crates/tools/src/direct.rs | 4 +- crates/tools/src/lib.rs | 1 + crates/tools/src/select.rs | 15 +- crates/tools/src/select/tests.rs | 24 ++ crates/tools/src/xform/transform.rs | 2 +- crates/ui-egui/src/app/view.rs | 5 +- crates/ui-egui/src/caches.rs | 5 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/transform.rs | 5 + crates/ui-egui/src/panels/transform/tests.rs | 20 ++ docs/control-protocol.md | 4 + docs/mcp.md | 23 ++ docs/target-app-parity.md | 1 + 61 files changed, 632 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/tests/typeglyphbounds.rs diff --git a/Cargo.lock b/Cargo.lock index d82f8255e..88a9abca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6499,6 +6499,7 @@ dependencies = [ "ruzstd", "serde", "serde_json", + "skrifa 0.47.0", "thiserror 2.0.21", "vectorcraft-affinity", "vectorcraft-apischema", diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 5ea0ff9bd..d6b7eda2c 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -750,6 +750,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64 if resized { o.cached_bounds = None; o.cached_baselines.clear(); + o.cached_ink = None; } } diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index f4a037f36..510de3a4d 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -347,6 +347,7 @@ impl Document { t.first_para_continues = false; t.cached_bounds = None; t.cached_baselines.clear(); + t.cached_ink = None; } let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs index 3ab3eb655..c5bf1972d 100644 --- a/crates/doc/src/inline.rs +++ b/crates/doc/src/inline.rs @@ -71,6 +71,7 @@ impl Document { } if changed { t.cached_bounds = None; + t.cached_ink = None; } changed } diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 3fdddd680..8f9b19704 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -236,6 +236,7 @@ impl TypeInks { text.xf = Affine::new([a, b, c, d, 0.0, 0.0]); text.cached_bounds = None; text.cached_baselines.clear(); + text.cached_ink = None; let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs)); match content.and_then(|key| self.content.get(&key)) { Some(ink) => { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 249b46d55..2d1a60e59 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -121,6 +121,11 @@ pub struct TextObject { /// vertical column's centre line), filled in with `cached_bounds` (not serialized). #[serde(skip)] pub cached_baselines: Vec<(Point, Point)>, + /// Cached ink of the glyphs in text space (`TextLayout::ink_bounds`, `None` when nothing draws), + /// with the `cached_bounds` it was measured with: filled in with `cached_bounds` by the engine's + /// text edits, and stale once `cached_bounds` differs ([`Self::ink`]). Not serialized. + #[serde(skip)] + pub cached_ink: Option<(Rect, Option<Rect>)>, } /// Conversion from stored character attributes to document-space type controls. diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index dc83c33cc..12bc7ec89 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -71,6 +71,7 @@ impl TextObject { self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); self.cached_bounds = None; self.cached_baselines.clear(); + self.cached_ink = None; true } @@ -97,6 +98,7 @@ impl TextObject { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, } } /// The list counts the story's earlier frames carry into this frame at each level @@ -288,6 +290,12 @@ impl TextObject { TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), } } + /// The cached ink of the glyphs in text space ([`Self::cached_ink`]): `Some(None)` when nothing + /// draws, and `None` when it was not measured with the current `cached_bounds`. + pub fn ink(&self) -> Option<Option<Rect>> { + let (bounds, ink) = self.cached_ink?; + (self.cached_bounds == Some(bounds)).then_some(ink) + } /// How far the characters' strokes paint past their glyphs, in document space: they scale /// with the type. pub fn char_stroke_outset(&self) -> f64 { diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index 5a619f8ac..ee8d4e1c1 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -17,6 +17,8 @@ //! [`Selection::anchor_bounds`] measure the objects themselves. Raster effects (shadows, glows, //! blurs) are not measured. In one object, the art of at most 4,096 symbol instances is measured //! with its geometry effects, and the other instances in it are measured without them. +//! [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure as these do and, +//! with [`Measure::glyphs`], take point type by the ink of its glyphs (Measure Type by Glyph Bounds). use std::cell::{Cell, RefCell}; use std::collections::HashMap; @@ -74,6 +76,111 @@ pub fn anchor_bounds(selection: &Selection, doc: &Document, preview: bool) -> Op union_opt(bounds_of(doc, &selection.whole(), preview), selection.anchors_box(doc)) } +/// How [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure objects: +/// `preview` as in [`bounds_of`], and with `glyphs` (Preferences › Type › Measure Type by Glyph +/// Bounds), point type, horizontal or vertical, by the ink of its glyphs instead of its em box +/// ([`text_ink_in`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Measure { + pub preview: bool, + pub glyphs: bool, +} + +impl Measure { + /// See [`by_glyphs`]. + fn by_glyphs(self, drawn: &Drawn, n: &Node) -> bool { + self.glyphs + && matches!(&n.kind, NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Point)) + && n.projection().is_none() + && !(self.preview && drawn.draws_geometry(n)) + } +} + +/// Whether `m` measures `n` by the ink of its glyphs: point type, not in perspective, and with +/// `preview` only when no geometry effect changes what it draws. +pub fn by_glyphs(symbols: &SymbolBoxes, n: &Node, m: Measure) -> bool { + m.by_glyphs(&Drawn::new(symbols), n) +} + +/// [`bounds_of`] measured by `m`. +pub fn bounds_of_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option<Rect> { + if !m.glyphs { + return bounds_of(doc, ids, m.preview); + } + let symbols = doc.symbol_boxes(); + let drawn = Drawn::new(&symbols); + if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) { + return bounds_of(doc, ids, m.preview); + } + ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, Affine::IDENTITY, m))) +} + +/// [`oriented_bounds`] measured by `m`. +pub fn oriented_bounds_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option<OrientedBox> { + if !m.glyphs { + return oriented_bounds(doc, ids, m.preview); + } + let symbols = doc.symbol_boxes(); + let drawn = Drawn::new(&symbols); + if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) { + return oriented_bounds(doc, ids, m.preview); + } + let angle = doc.bbox_angle(ids); + let to_local = if angle == 0.0 { Affine::IDENTITY } else { OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse() }; + let rect = ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, to_local, m)))?; + Some(OrientedBox { angle, rect }) +} + +/// [`anchor_bounds`] with the objects selected whole measured by `m`. +pub fn anchor_bounds_with(selection: &Selection, doc: &Document, m: Measure) -> Option<Rect> { + if selection.anchors.is_empty() { + return None; + } + union_opt(bounds_of_with(doc, &selection.whole(), m), selection.anchors_box(doc)) +} + +/// The box of `n` as `a` would place it, without changing it, measured by `m`: [`bounds_in`] with +/// `preview`, else [`Node::bounds_in`] of its geometric bounds, and point type by its glyphs as +/// [`Measure`] says. +pub fn bounds_in_with(symbols: &SymbolBoxes, n: &Node, a: Affine, m: Measure) -> Option<Rect> { + measured_in(&Drawn::new(symbols), n, a, m) +} + +/// See [`bounds_in_with`]. +fn measured_in(drawn: &Drawn, n: &Node, a: Affine, m: Measure) -> Option<Rect> { + if m.by_glyphs(drawn, n) + && let NodeKind::Text(t) = &n.kind + && let Some(r) = text_ink_in(t, a) + { + // The fills' and characters' strokes paint around the glyphs, as in `Node::visual_bounds`. + let o = if m.preview { n.appearance.outset().max(t.char_stroke_outset()) } else { 0.0 }; + return Some(r.inflate(o, o)); + } + match (m.preview, a == Affine::IDENTITY) { + (true, _) => drawn.next().bounds_in(n, a), + (false, true) => n.geometric_bounds_with(drawn.symbols), + (false, false) => n.bounds_in(drawn.symbols, a, false), + } +} + +/// The box of the ink of type `t`'s glyphs with its text space mapped by `a * t.xf`: the cached +/// ink ([`vectorcraft_doc::TextObject::ink`]) when that map keeps a rectangle square, else the +/// glyph outlines mapped point by point ([`vectorcraft_text::TextLayout::ink_bounds_in`]). `None` +/// when nothing draws (no text, only spaces, or glyphs without outlines). +pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option<Rect> { + let m = a * t.xf; + let [p, q, r, s, _, _] = m.as_coeffs(); + let square = (q.abs() <= 1e-12 && r.abs() <= 1e-12) || (p.abs() <= 1e-12 && s.abs() <= 1e-12); + if square { + let ink = match t.ink() { + Some(ink) => ink, + None => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds(), + }; + return ink.map(|i| m.transform_rect_bbox(i)); + } + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds_in(m) +} + /// The visual bounds of `n` as drawn: those of its [`drawn_art`], else /// [`Node::visual_bounds_with`]. pub fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 10bb22599..ef403dc80 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -88,3 +88,5 @@ zune-jpegxl = { workspace = true } zune-core = { workspace = true } # The JavaScript parser's lexer, compared with the script checker's on the same scripts. vectorcraft-js = { workspace = true } +# A bundled font's own glyph boxes, for the glyph bounds tests. +skrifa = { workspace = true } diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 4b03f0e43..dfb450fae 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -275,6 +275,7 @@ fn same_art(a: &NodeKind, b: &NodeKind) -> bool { moved.xf = a.xf; moved.cached_bounds = a.cached_bounds; moved.cached_baselines.clone_from(&a.cached_baselines); + moved.cached_ink = a.cached_ink; **a == moved } } diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 82a61b02a..b32275d1b 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -6,16 +6,23 @@ use super::{ use vectorcraft_doc::{NodeKind, TextKind}; impl Session { - /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: - /// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included, - /// see [`effects::preview`]), else geometric bounds. + /// The bounds of `ids` the Transform panel and the bounding box measure: with Use Preview + /// Bounds on, visual bounds as drawn (strokes and geometry effects included, see + /// [`effects::preview`]), else geometric bounds; with Measure Type by Glyph Bounds on, point + /// type by the ink of its glyphs ([`Session::bounds_measure`]). pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> { - effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) + effects::preview::bounds_of_with(&self.doc().ok()?.doc, ids, self.bounds_measure()) } /// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding /// box, see [`Document::oriented_bounds`]). pub fn transform_box(&self, ids: &[NodeId]) -> Option<OrientedBox> { - effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) + effects::preview::oriented_bounds_with(&self.doc().ok()?.doc, ids, self.bounds_measure()) + } + /// How the bounding box, the Transform panel and `object.setBounds` measure objects: Use + /// Preview Bounds, and point type by its glyphs with Measure Type by Glyph Bounds + /// (`typeGlyphBounds`). + pub fn bounds_measure(&self) -> effects::preview::Measure { + effects::preview::Measure { preview: self.prefs.use_preview_bounds, glyphs: self.prefs.type_glyph_bounds } } } diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index df2c1ffd1..3dc1c37da 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -59,24 +59,31 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor let st = s.doc()?; check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?; check_switches(p, &["proportional"], "object.setBounds")?; + let how = s.bounds_measure(); if anchors && transforms_objects(p) && !bool_or(p, "copy", false) - && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) + && let Some(b) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, how) { return Ok(Bounds::Anchors(ids, b)); } let d = &s.doc()?.doc; // Sizes are measured square to the bounding box, which a rotation turns with the objects: - // the math below runs in its frame. - let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + // the math below runs in its frame. With Measure Type by Glyph Bounds, point type is measured + // by its glyphs' ink, which a transform of the type moves and scales with it. + let geometric = effects::preview::Measure { preview: false, ..how }; + let frame = effects::preview::oriented_bounds_with(d, &ids, geometric).ok_or_else(|| EngineError::Other("no bounds".into()))?; let g = frame.rect; + let symbols = d.symbol_boxes(); + let by_glyphs = how.glyphs && ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::by_glyphs(&symbols, n, how)); // With Use Preview Bounds the values measure the visual box as drawn, whose margins around the // geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of // `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their - // weight when an instance scales, so the rest of the margins stay. + // weight when an instance scales, so the rest of the margins stay. Type measured by its glyphs + // is measured as drawn after each scale tried instead (below). let (v, vs) = if s.prefs.use_preview_bounds { - (effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g)) + let v = effects::preview::oriented_bounds_with(d, &ids, how).map_or(g, |b| b.rect); + (v, if by_glyphs { v } else { scaling_box(d, &ids, &frame).unwrap_or(g) }) } else { (g, g) }; @@ -125,16 +132,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor // The scales and the point above can miss the size or the reference point when the margins of // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one - // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and + // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, // objects with a butt-capped stroke along a segment at a slant, whose margins change with the - // segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]). - let symbols = d.symbol_boxes(); + // segment's direction, and type measured by its glyphs, are measured as drawn after each scale + // tried instead ([`refit_drawn`]). let to_local = frame.to_doc().inverse(); let drawn = || { - ids.iter() - .filter_map(|id| d.node(*id)) - .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + by_glyphs + || ids + .iter() + .filter_map(|id| d.node(*id)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) }; + // Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink + // included), so the scales above are exact and nothing is refitted. let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { None } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { @@ -145,7 +156,7 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| { let mut n = n.clone(); n.transform(xf, sc); - vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local)) + vectorcraft_geom::union_opt(acc, effects::preview::bounds_in_with(&symbols, &n, to_local, how)) }) }; // The scales above keep the effects' reach as a margin that stays, and give 0 for a size diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index a923ab2eb..086bf3843 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -287,7 +287,7 @@ pub fn specs() -> Vec<CommandSpec> { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, point type selected on its own is measured by its glyphs' ink, which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index ae45f4d97..618486097 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -12,7 +12,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> { // Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre. let st = s.doc()?; if !bool_or(p, "copy", false) - && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) + && let Some(b) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()) { return Ok(b.center()); } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f3cf6416f..dde754843 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -434,6 +434,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // The Align panel menu's Align to Glyph Bounds: Align and Distribute measure type by its glyphs' ink. p!("alignGlyphBoundsPoint", "Type", "Align to Glyph Bounds", "Point Text", bool), p!("alignGlyphBoundsArea", "Type", "Align to Glyph Bounds", "Area Text", bool), + // The bounding box and the Transform panel measure point type by its glyphs' ink (also in the Transform panel menu). + p!("typeGlyphBounds", "Type", "Glyph Bounds", "Measure Type by Glyph Bounds", bool), // Artboard Options › Global: the canvas darkens the area outside the artboards while the Artboard tool is active. p!("fadeRegionOutsideArtboard", "General", "Artboard Options", "Fade Region Outside Artboard", bool), p!("artboardUpdateWhileDragging", "General", "Artboard Options", "Update While Dragging", bool), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index a1bb2c64e..b6d20999a 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1611,6 +1611,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, }; super::create::new_type_alignment(s, p, C, &mut t)?; if !on_path { @@ -1901,6 +1902,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, }; for r in &mut h.runs { r.style.tracking = tr; diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index c0e5b37fe..1bf212717 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -304,6 +304,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); + t.cached_ink = Some((lay.bounds, lay.ink_bounds())); } /// [`refresh_bounds`] for a document that is opening: the first frame takes the layout @@ -315,6 +316,7 @@ pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); + t.cached_ink = Some((lay.bounds, lay.ink_bounds())); vectorcraft_text::keep_layout(t, stamp, lay); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d3b034526..e6fa7cb8d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -41,6 +41,11 @@ pub struct Prefs { /// Align panel menu › Align to Glyph Bounds › Area Text: the same for area type, instead of /// its frame. pub align_glyph_bounds_area: bool, + /// Preferences › Type › Glyph Bounds › Measure Type by Glyph Bounds (also in the Transform + /// panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, + /// Y, W and H, and `object.setBounds` measure point type selected on its own by the ink of its + /// glyphs instead of its em box ([`crate::Session::bounds_measure`]). + pub type_glyph_bounds: bool, /// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the /// canvas draws everything outside the artboards darker, the pasteboard and the art on it. pub fade_region_outside_artboard: bool, @@ -370,6 +375,7 @@ impl Default for Prefs { use_preview_bounds: false, align_glyph_bounds_point: false, align_glyph_bounds_area: false, + type_glyph_bounds: false, fade_region_outside_artboard: true, artboard_update_while_dragging: true, display_print_size: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 000741d5d..6063feab4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -186,6 +186,7 @@ mod transformfx; mod transparencygrid; mod trap; mod typearea; +mod typeglyphbounds; mod typescale; mod typeundo; mod units; diff --git a/crates/engine/src/tests/typeglyphbounds.rs b/crates/engine/src/tests/typeglyphbounds.rs new file mode 100644 index 000000000..0f42fcc7f --- /dev/null +++ b/crates/engine/src/tests/typeglyphbounds.rs @@ -0,0 +1,297 @@ +//! Preferences › Type › Measure Type by Glyph Bounds (`typeGlyphBounds`): the bounding box, the +//! Transform panel's values and `object.setBounds` measure point type selected on its own by the +//! ink of its glyphs instead of its em box. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind, OrientedBox, TextObject}; +use vectorcraft_geom::Rect; + +use crate::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A new document holding point type made by `text.create` with `params`, selected. +fn with_type(params: Value) -> (Session, NodeId) { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 800, "height": 800})); + let t = id(run(&mut s, "text.create", params)); + run(&mut s, "select.set", json!({"ids": [t.0]})); + (s, t) +} + +fn glyphs(s: &mut Session, on: bool) { + run(s, "prefs.set", json!({"values": {"typeGlyphBounds": on}})); +} + +fn text(s: &Session, id: NodeId) -> TextObject { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("{id:?} is not type") }; + (**t).clone() +} + +/// The ink of the type's glyphs on the page, laid out afresh (not from the cache). +fn ink(s: &Session, id: NodeId) -> Rect { + let t = text(s, id); + t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()) +} + +/// The em box (the line box) of point type: what the bounding box measures by default. +fn em(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn near(a: Rect, b: Rect, tol: f64) -> bool { + (a.x0 - b.x0).abs() <= tol && (a.y0 - b.y0).abs() <= tol && (a.x1 - b.x1).abs() <= tol && (a.y1 - b.y1).abs() <= tol +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The glyph box of `c` in Inter Regular, in font units (x min, y min, x max, y max), and the +/// font's units per em, read from the font file's own `head`, `cmap`, `loca` and `glyf` tables. +fn inter_glyph_box(c: char) -> ([f64; 4], f64) { + use skrifa::MetadataProvider; + use skrifa::raw::TableProvider; + let font = skrifa::FontRef::new(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let upem = f64::from(font.head().unwrap().units_per_em()); + let gid = font.charmap().map(c).unwrap(); + let glyph = font.loca(None).unwrap().get_glyf(gid, &font.glyf().unwrap()).unwrap().unwrap(); + ([glyph.x_min(), glyph.y_min(), glyph.x_max(), glyph.y_max()].map(f64::from), upem) +} + +/// "H" in Inter at 100 pt is drawn with straight lines only, so the box of its outline is the glyph +/// box the font file stores: the bounding box takes its size and place from the font itself. +#[test] +fn point_type_measures_the_glyph_box_the_font_file_stores() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "font": "Inter", "text": "H"})); + glyphs(&mut s, true); + let ([x0, y0, x1, y1], upem) = inter_glyph_box('H'); + let k = 100.0 / upem; + let b = s.transform_box(&[t]).unwrap(); + assert_eq!(b.angle, 0.0); + // Text space runs down the page from the baseline at y = 300. + let want = Rect::new(100.0 + x0 * k, 300.0 - y1 * k, 100.0 + x1 * k, 300.0 - y0 * k); + assert!(near(b.rect, want, 1e-6), "{:?} != {want:?}", b.rect); + assert!(near(s.transform_bounds(&[t]).unwrap(), want, 1e-6)); + // The em box is taller than the cap height. + assert!(em(&s, t).height() > b.rect.height() + 10.0, "em box {:?}", em(&s, t)); +} + +#[test] +fn off_by_default_the_box_is_the_em_box() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + assert!(!s.prefs.type_glyph_bounds); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t)); + glyphs(&mut s, true); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-9)); + glyphs(&mut s, false); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t)); +} + +/// A typed height scales the type so its ink takes that height, in one undo step, and the type +/// stays type. +#[test] +fn set_bounds_gives_the_ink_the_typed_size() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let xf = text(&s, t).xf; + let n = undo_len(&s); + run(&mut s, "object.setBounds", json!({"height": 50})); + assert_eq!(undo_len(&s), n + 1); + assert!((ink(&s, t).height() - 50.0).abs() < 1e-6, "{:?}", ink(&s, t)); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-6)); + assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, t).xf, xf); +} + +/// A typed X moves the ink box's reference point there and keeps its size; a typed width gives +/// the ink that width. +#[test] +fn set_bounds_moves_and_sizes_the_ink_box() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let before = ink(&s, t); + run(&mut s, "object.setBounds", json!({"x": 400, "reference": 0})); + let moved = ink(&s, t); + assert!((moved.x0 - 400.0).abs() < 1e-6 && (moved.y0 - before.y0).abs() < 1e-6, "{moved:?}"); + assert!((moved.width() - before.width()).abs() < 1e-6 && (moved.height() - before.height()).abs() < 1e-6, "{moved:?}"); + run(&mut s, "object.setBounds", json!({"width": 123})); + assert!((ink(&s, t).width() - 123.0).abs() < 1e-6, "{:?}", ink(&s, t)); + assert!((ink(&s, t).height() - before.height()).abs() < 1e-6, "{:?}", ink(&s, t)); +} + +#[test] +fn set_bounds_with_the_current_values_records_no_undo_step() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + let n = undo_len(&s); + run(&mut s, "object.setBounds", json!({"x": b.center().x, "y": b.center().y, "width": b.width(), "height": b.height()})); + assert_eq!(undo_len(&s), n); + assert_eq!(s.transform_box(&[t]).unwrap().rect, b); +} + +/// A proportional width scales both sides of the ink by the same factor, about the reference +/// point of the ink box (here its top-left corner). +#[test] +fn proportional_width_keeps_the_ink_in_proportion_about_the_reference_point() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let before = ink(&s, t); + run(&mut s, "object.setBounds", json!({"width": before.width() * 2.0, "proportional": true, "reference": 0})); + let after = ink(&s, t); + assert!((after.width() - before.width() * 2.0).abs() < 1e-6, "{after:?}"); + assert!((after.height() - before.height() * 2.0).abs() < 1e-6, "{after:?}"); + assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} -> {after:?}"); +} + +/// Rotated type keeps its own angle, and the box is the size of the ink before the rotation. A +/// skewed object's box is the box of its glyph outlines, not of its mapped ink rectangle. +#[test] +fn rotated_and_skewed_type_measure_their_outlines() { + let (mut s, flat) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let size = s.transform_box(&[flat]).unwrap().rect.size(); + let t = id(run(&mut s, "text.create", json!({"x": 300, "y": 300, "size": 100, "text": "Hg", "rotation": 30}))); + let b = s.transform_box(&[t]).unwrap(); + assert_eq!(b.angle, s.doc().unwrap().doc.oriented_bounds(&[t], false).unwrap().angle); + assert!(b.angle != 0.0); + assert!((b.rect.width() - size.width).abs() < 1e-6 && (b.rect.height() - size.height).abs() < 1e-6, "{b:?} vs {size:?}"); + + let o = id(run(&mut s, "text.create", json!({"x": 100, "y": 600, "size": 100, "text": "o"}))); + run(&mut s, "select.set", json!({"ids": [o.0]})); + run(&mut s, "object.transform", json!({"matrix": [1, 0, 0.6, 1, -360, 0]})); + let b = s.transform_box(&[o]).unwrap(); + let tx = text(&s, o); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx); + let to_local = OrientedBox { angle: b.angle, rect: Rect::ZERO }.to_doc().inverse(); + let want = lay.ink_bounds_in(to_local * tx.xf).unwrap(); + assert!(near(b.rect, want, 1e-9), "{:?} != {want:?}", b.rect); + let mapped = (to_local * tx.xf).transform_rect_bbox(lay.ink_bounds().unwrap()); + assert!(mapped.width() > b.rect.width() + 1.0, "a round letter's outline is narrower than its slanted box"); +} + +/// Type that draws nothing is measured by its em box. +#[test] +fn empty_and_space_only_type_keep_the_em_box() { + for text in ["", " "] { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 40, "text": text})); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t), "{text:?}"); + } +} + +/// Area type keeps its frame, and type on a path its path. +#[test] +fn area_type_and_type_on_a_path_are_measured_as_before() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 800, "height": 800})); + let area = id(run(&mut s, "text.create", json!({"x": 100, "y": 100, "size": 24, "text": "ace", "area": {"width": 200, "height": 100}}))); + let line = id(run(&mut s, "shape.line", json!({"x1": 100, "y1": 400, "x2": 500, "y2": 400}))); + let path = id(run(&mut s, "text.createInPath", json!({"path": line.0, "mode": "onPath", "text": "ace", "size": 24}))); + for t in [area, path] { + let off = s.transform_box(&[t]).unwrap(); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[t]).unwrap(), off, "{t:?}"); + glyphs(&mut s, false); + } +} + +#[test] +fn vertical_point_type_measures_its_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 100, "size": 60, "text": "ace", "vertical": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, ink(&s, t), 1e-9), "{b:?} != {:?}", ink(&s, t)); + assert!(em(&s, t).width() > b.width() + 1.0, "em box {:?}", em(&s, t)); +} + +/// With Use Preview Bounds, the characters' strokes paint around the glyphs. +#[test] +fn preview_bounds_add_the_character_strokes_to_the_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + run(&mut s, "text.setStyle", json!({"ids": [t.0], "stroke": "#000000", "strokeWidth": 6})); + run(&mut s, "prefs.set", json!({"values": {"typeGlyphBounds": true, "usePreviewBounds": true}})); + let o = text(&s, t).char_stroke_outset(); + assert!(o > 0.0); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, ink(&s, t).inflate(o, o), 1e-9), "{b:?}"); + // A typed height is the height with the strokes. + run(&mut s, "object.setBounds", json!({"height": 80})); + assert!((s.transform_box(&[t]).unwrap().rect.height() - 80.0).abs() < 1e-6, "{:?}", s.transform_box(&[t])); +} + +/// Type inside a selected group is measured as before: the group's box does not change. +#[test] +fn a_group_holding_type_keeps_its_box() { + let (mut s, _) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let g = id(run(&mut s, "object.group", json!({}))); + let off = s.transform_box(&[g]).unwrap(); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[g]).unwrap(), off); +} + +/// Runs in two fonts: the box covers the ink of both, and follows an edit of the type. +#[test] +fn mixed_fonts_measure_the_ink_of_every_run() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hgxo"})); + glyphs(&mut s, true); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "font": "Source Serif 4", "start": 2, "end": 4})); + let tx = text(&s, t); + assert!(tx.runs.iter().any(|r| r.style.font_family == "Source Serif 4") && tx.runs.len() > 1, "{:?}", tx.runs); + assert!(tx.ink().is_some(), "the edit refreshed the cached ink"); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-9)); +} + +/// A selection of type and a shape is measured by the type's ink and the shape's bounds. +#[test] +fn a_mixed_selection_takes_the_ink_and_the_shapes_bounds() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let r = id(run(&mut s, "shape.rectangle", json!({"x": 500, "y": 500, "width": 50, "height": 50}))); + glyphs(&mut s, true); + assert!(near(s.transform_bounds(&[t, r]).unwrap(), ink(&s, t).union(Rect::new(500.0, 500.0, 550.0, 550.0)), 1e-9)); +} + +#[test] +fn the_preference_is_checked_and_saved() { + let mut s = Session::new(); + assert!(s.execute("prefs.set", &json!({"values": {"typeGlyphBounds": "yes"}})).is_err()); + assert!(!s.prefs.type_glyph_bounds); + assert!(!serde_json::from_value::<Prefs>(json!({})).unwrap().type_glyph_bounds); + run(&mut s, "prefs.set", json!({"key": "typeGlyphBounds", "value": true})); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["typeGlyphBounds"], json!(true)); + assert!(serde_json::from_value::<Prefs>(saved).unwrap().type_glyph_bounds); + assert_eq!(run(&mut s, "prefs.get", json!({"key": "typeGlyphBounds"})), json!(true)); +} + +/// The preference is not part of the document: a saved document is the same with it on or off. +#[test] +fn saved_documents_do_not_depend_on_the_preference() { + let (mut s, _) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let off = serde_json::to_string(&s.doc().unwrap().doc).unwrap(); + assert!(!off.contains("cached_ink") && !off.contains("cachedInk"), "the cached ink is not saved"); + glyphs(&mut s, true); + assert_eq!(serde_json::to_string(&s.doc().unwrap().doc).unwrap(), off); +} + +/// A transform of type keeps its cached ink, which is in text space. +#[test] +fn the_cached_ink_is_the_laid_out_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let tx = text(&s, t); + assert_eq!(tx.ink(), Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds())); + run(&mut s, "object.transform", json!({"matrix": [2, 0, 0, 3, 0, 0]})); + assert_eq!(text(&s, t).ink(), tx.ink()); + // A cache measured with other bounds is not read. + let mut stale = text(&s, t); + stale.cached_bounds = Some(Rect::new(0.0, 0.0, 1.0, 1.0)); + assert_eq!(stale.ink(), None); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d8b32eab7..e818c3686 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -365,6 +365,7 @@ impl Session { freeform_point: self.selected_freeform_point(), raster_sample: self.prefs.eyedropper.sample_size, preview_bounds: self.prefs.use_preview_bounds, + glyph_bounds: self.prefs.type_glyph_bounds, preview_memo: Some(&self.preview_memo), unit, stroke_unit, diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 054db258d..971323bf1 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -178,6 +178,9 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, + /// Type › Measure Type by Glyph Bounds: the bounding box measures point type selected on its + /// own by the ink of its glyphs ([`vectorcraft_effects::preview::Measure`]). + pub glyph_bounds: bool, /// The session's memo of the selection tools' measurements for the document as it is /// ([`select::PreviewMemo`]); without one they measure on every call. pub preview_memo: Option<&'a select::PreviewMemo>, @@ -284,6 +287,11 @@ pub struct ToolContext<'a> { } impl ToolContext<'_> { + /// How the bounding box measures the selection: [`Self::preview_bounds`] and + /// [`Self::glyph_bounds`]. + pub fn bounds_measure(&self) -> vectorcraft_effects::preview::Measure { + vectorcraft_effects::preview::Measure { preview: self.preview_bounds, glyphs: self.glyph_bounds } + } /// Tolerance in document units for `px` screen pixels. pub fn tol(&self, px: f64) -> f64 { px / self.zoom.max(1e-9) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a2ffbbf87..833f48225 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1437,7 +1437,7 @@ impl DirectSelectionTool { // them, an edge on a gridline on each axis), not that point (#3835). let grid = segment && (cx.snap_to_grid || cx.snap_to_pixel); self.partial_grid = - grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); } let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let unsnapped = d; @@ -1474,7 +1474,7 @@ impl DirectSelectionTool { self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snap_to_pixel); self.partial_grid = - grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); let copy = ev.mods.alt && self.partial_snap.is_none(); out.push(Action::Begin(if copy { "Copy".into() } else { "Move".into() })); self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 557fe52b1..b4a7d6997 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -536,6 +536,7 @@ pub(crate) mod testutil { freeform_point: None, raster_sample: 1, preview_bounds: false, + glyph_bounds: false, preview_memo: None, unit: Unit::Points, stroke_unit: Unit::Points, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index e39837c7c..fe666bd67 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -419,7 +419,7 @@ struct MemoState { struct Measured { revision: (u64, u64), objects: Vec<NodeId>, - preview: bool, + measure: vectorcraft_effects::preview::Measure, bounds: Option<Option<Rect>>, oriented: Option<Option<OrientedBox>>, by_object: HashMap<NodeId, Option<Option<Rect>>>, @@ -427,7 +427,7 @@ struct Measured { impl Measured { fn is_for(&self, cx: &ToolContext) -> bool { - self.revision == cx.revision && self.preview == cx.preview_bounds && self.objects == cx.selection.objects + self.revision == cx.revision && self.measure == cx.bounds_measure() && self.objects == cx.selection.objects } } @@ -450,7 +450,7 @@ impl PreviewMemo { let fresh = || Measured { revision: cx.revision, objects: cx.selection.objects.clone(), - preview: cx.preview_bounds, + measure: cx.bounds_measure(), bounds: None, oriented: None, by_object: HashMap::new(), @@ -487,15 +487,16 @@ fn remembered<T: Clone>(cx: &ToolContext, slot: impl Fn(&mut Measured) -> &mut O } /// Selection bounds used for the bounding box: visual bounds as drawn with Use Preview Bounds -/// ([`vectorcraft_effects::preview`]), else geometric bounds. +/// ([`vectorcraft_effects::preview`]), else geometric bounds, with point type measured by its +/// glyphs with Measure Type by Glyph Bounds ([`ToolContext::bounds_measure`]). pub fn selection_bounds(cx: &ToolContext) -> Option<Rect> { - remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of(cx.doc, &cx.selection.objects, cx.preview_bounds)) + remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of_with(cx.doc, &cx.selection.objects, cx.bounds_measure())) } /// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool /// shows and drags (rotated after a rotation). pub fn selection_box(cx: &ToolContext) -> Option<OrientedBox> { - remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds(cx.doc, &cx.selection.objects, cx.preview_bounds)) + remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds_with(cx.doc, &cx.selection.objects, cx.bounds_measure())) } /// [`selection_box`] while the canvas shows it (View › Show Bounding Box, and no anchors selected @@ -527,7 +528,7 @@ fn object_bounds(cx: &ToolContext, id: NodeId) -> Option<Rect> { /// which compares the whole selection: a walk of every selected object checking it each time takes /// time in the square of their number. fn with_object_bounds<R>(cx: &ToolContext, walk: impl FnOnce(&mut dyn FnMut(NodeId) -> Option<Rect>) -> R) -> R { - let mut measure = |id: NodeId| vectorcraft_effects::preview::bounds_of(cx.doc, &[id], cx.preview_bounds); + let mut measure = |id: NodeId| vectorcraft_effects::preview::bounds_of_with(cx.doc, &[id], cx.bounds_measure()); let Some(mut st) = cx.preview_memo.and_then(|memo| memo.0.try_borrow_mut().ok()) else { return walk(&mut measure); }; diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 9921d8f5b..2b1151742 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2255,6 +2255,30 @@ fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() { assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0)); } +/// With Measure Type by Glyph Bounds, the bounding box of selected point type is the ink of its +/// glyphs; the memo measures again when only that setting changes. +#[test] +fn the_bounding_box_of_point_type_follows_measure_type_by_glyph_bounds() { + use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject}; + let mut d = Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let text = TextObject::point(Point::new(100.0, 300.0), "Hg", CharStyle { size: 60.0, ..Default::default() }); + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text).ink_bounds().unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let (mut s, p, memo) = (Selection::default(), paint(), PreviewMemo::default()); + s.set([id]); + let at = |glyph_bounds| { + let c = ToolContext { revision: (1, 1), glyph_bounds, preview_memo: Some(&memo), ..cx(&d, &s, &p) }; + (selection_box(&c).map(|b| b.rect), object_bounds(&c, id)) + }; + let em = d.node(id).and_then(|n| n.geometric_bounds()); + assert_eq!(at(false), (em, em)); + let glyphs = Some(text.xf.transform_rect_bbox(ink)); + assert_eq!(at(true), (glyphs, glyphs)); + assert_ne!(glyphs, em); + assert_eq!(memo.measurements(), 4, "the same revision measured again for the setting"); +} + /// A double-click on a symbol instance edits its symbol in symbol editing mode (`symbol.edit`), and /// one outside the symbol's art in that mode leaves it, as it leaves isolation mode. An instance /// inside the art of the symbol being edited is not entered. diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 04ecc971b..5cabbed6b 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -118,7 +118,7 @@ impl TransformTool { return Some(*p); } // Anchors selected with Direct Selection transform alone, about their own centre. - vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds) + vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()) .map(|b| b.center()) .or_else(|| selection_box(cx).map(|b| b.center())) } diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 989514991..6aa3eab76 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -137,13 +137,14 @@ impl VectorcraftApp { /// frame: it is measured once per revision. pub fn selection_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> { let st = self.session.active()?; - let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); + let measure = self.session.bounds_measure(); + let key = (st.uid, st.revision, measure); if let Some((k, b)) = self.canvas.selection_box && k == key { return b; } - let b = match vectorcraft_effects::preview::anchor_bounds(&st.selection, &st.doc, self.session.prefs.use_preview_bounds) { + let b = match vectorcraft_effects::preview::anchor_bounds_with(&st.selection, &st.doc, measure) { Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)), None => self.session.transform_box(&st.selection.objects), }; diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index d8ec2b10e..1e69fc57d 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -51,8 +51,9 @@ pub struct CanvasCache { /// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that /// does not draw them drops them ([`canvas::show`]). pub selection_marks: canvas::SelectionMarks, - /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). - pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>, + /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds and + /// Measure Type by Glyph Bounds). + pub selection_box: Option<((u64, u64, vectorcraft_effects::preview::Measure), Option<vectorcraft_doc::OrientedBox>)>, /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). pub selection_bounds: Option<((u64, u64), Option<vectorcraft_geom::Rect>)>, /// [`VectorcraftApp::content_bounds`] for (document uid, revision). diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b7006..1287aae70 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5896,6 +5896,8 @@ axis Y Axis Y ос # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точков текст Area Text Текст в зона +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Измерване на текста по границите на глифа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показване на опциите за прилепване към глиф Hide Snap to Glyph Options Скриване на опциите за прилепване към глиф diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a655..8c11a1cb0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5952,6 +5952,8 @@ axis Y Axis Y osa Live preview Pregled uživo Point Text Tačkasti tekst Area Text Tekst u oblasti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mjeri tekst po granicama glifa Show Snap to Glyph Options Prikaži opcije prianjanja uz glif Hide Snap to Glyph Options Sakrij opcije prianjanja uz glif Snap to Glyph Options Opcije prianjanja uz glif diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index bc984eec5..489ece200 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6067,6 +6067,8 @@ theme Appearance Aparença # Align panel menu › Align to Glyph Bounds (#4231). Point Text Text de punt Area Text Text d’àrea +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mesura el text pels límits del glif # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra les opcions d’ajust al glif Hide Snap to Glyph Options Amaga les opcions d’ajust al glif diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fc357028f..707dfb8d8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6104,6 +6104,8 @@ axis Y Axis osa Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Bodový text Area Text Text v oblasti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Měřit text podle hranic glyfů # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Zobrazit možnosti přichytávání ke glyfům Hide Snap to Glyph Options Skrýt možnosti přichytávání ke glyfům diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718b..c860cf011 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5911,6 +5911,8 @@ axis Y Axis Y-akse # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttekst Area Text Områdetekst +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mål tekst efter glyfafgrænsning # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Vis indstillinger for fastgørelse til tegn Hide Snap to Glyph Options Skjul indstillinger for fastgørelse til tegn diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b66afbe45..9cc4bd50b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5687,6 +5687,8 @@ axis Y Axis Y-Achse # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttext Area Text Flächentext +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Text an Glyphenbegrenzung messen # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Optionen für „An Glyphe ausrichten“ einblenden Hide Snap to Glyph Options Optionen für „An Glyphe ausrichten“ ausblenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 632a6feec..b2e307d21 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6055,6 +6055,8 @@ axis Y Axis Άξονας Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Κείμενο σημείου Area Text Κείμενο περιοχής +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Μέτρηση κειμένου με βάση τα όρια γλυφής # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Εμφάνιση επιλογών προσκόλλησης σε γλυφή Hide Snap to Glyph Options Απόκρυψη επιλογών προσκόλλησης σε γλυφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3369528a1..99b717e2f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6046,6 +6046,8 @@ axis Y Axis Eje Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texto de punto Area Text Texto de área +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Medir el texto por los límites del glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar opciones de ajuste a glifo Hide Snap to Glyph Options Ocultar opciones de ajuste a glifo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ba0cff27..f23a250cc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6134,6 +6134,8 @@ axis Y Axis Y-akseli # Align panel menu › Align to Glyph Bounds (#4231). Point Text Pisteteksti Area Text Alueteksti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mittaa teksti merkin rajoista # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Näytä merkkiin kohdistuksen asetukset Hide Snap to Glyph Options Piilota merkkiin kohdistuksen asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6a7817aea..063551fac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6099,6 +6099,8 @@ axis Y Axis Axe Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texte de point Area Text Texte captif +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mesurer le texte d’après les limites du glyphe # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Afficher les options de magnétisme du glyphe Hide Snap to Glyph Options Masquer les options de magnétisme du glyphe diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1d5571d2d..f72d85e92 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -394,6 +394,7 @@ antiAlias Sharp शार्प Area / Path Type एरिया / पाथ टाइप Area Graph Tool एरिया ग्राफ़ टूल Area Text एरिया टेक्स्ट + Measure Type by Glyph Bounds टाइप को ग्लिफ़ की सीमाओं से मापें Area Text Objects एरिया टेक्स्ट ऑब्जेक्ट Area Type एरिया टाइप Area Type Options एरिया टाइप ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 92d7822e4..f065068ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6087,6 +6087,8 @@ axis Y Axis Y tengely # Align panel menu › Align to Glyph Bounds (#4231). Point Text Pontszöveg Area Text Területszöveg +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Szöveg mérése a glifahatárok szerint # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glifához illesztés beállításainak megjelenítése Hide Snap to Glyph Options Glifához illesztés beállításainak elrejtése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f..83d51d692 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5911,6 +5911,8 @@ axis Y Axis Sumbu Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Teks titik Area Text Teks area +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Ukur teks menurut batas glif # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Tampilkan opsi snap ke glif Hide Snap to Glyph Options Sembunyikan opsi snap ke glif diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f710a566..f9c87895e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5721,6 +5721,8 @@ axis Y Axis Asse Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Testo per punti Area Text Testo in area +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Misura il testo in base ai limiti del glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra opzioni di aggancio a glifo Hide Snap to Glyph Options Nascondi opzioni di aggancio a glifo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6403b1e8a..2c219e926 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5786,6 +5786,8 @@ axis Y Axis Y 軸 # Align panel menu › Align to Glyph Bounds (#4231). Point Text ポイント文字 Area Text エリア内文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 文字を字形の境界で測定 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options グリフにスナップのオプションを表示 Hide Snap to Glyph Options グリフにスナップのオプションを隠す diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0b6755253..f333baeeb 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6100,6 +6100,8 @@ swatch Bright Violet კაშკაშა იისფერი # Align panel menu › Align to Glyph Bounds (#4231). Point Text წერტილოვანი ტექსტი Area Text არის ტექსტი +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds ტექსტის გაზომვა გლიფის საზღვრებით # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options გლიფზე მიბმის პარამეტრების ჩვენება Hide Snap to Glyph Options გლიფზე მიბმის პარამეტრების დამალვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 64844bf7c..406af4f70 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5682,6 +5682,8 @@ axis Y Axis Y축 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 포인트 문자 Area Text 영역 문자 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 글리프 경계로 문자 측정 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 글리프에 스냅 옵션 표시 Hide Snap to Glyph Options 글리프에 스냅 옵션 숨기기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3..19028d534 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5962,6 +5962,8 @@ axis Y Axis Y-as # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punttekst Area Text Vlaktekst +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Tekst meten op glyphbegrenzing # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Opties voor uitlijnen op glyph tonen Hide Snap to Glyph Options Opties voor uitlijnen op glyph verbergen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd33ebd05..20b179393 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5958,6 +5958,8 @@ axis Y Axis Oś Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Tekst punktowy Area Text Tekst akapitowy +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mierz tekst według granic glifów # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Pokaż opcje przyciągania do glifów Hide Snap to Glyph Options Ukryj opcje przyciągania do glifów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a80de1e0c..956356193 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6019,6 +6019,8 @@ axis Y Axis Eixo Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texto de Ponto Area Text Texto de Área +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Medir Texto pelos Limites do Glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar Opções de Ajuste ao Glifo Hide Snap to Glyph Options Ocultar Opções de Ajuste ao Glifo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2ddc692a3..294ac503e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6081,6 +6081,8 @@ theme Appearance Оформление # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точечный текст Area Text Текст в области +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Измерять текст по границам глифа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показать параметры привязки к глифу Hide Snap to Glyph Options Скрыть параметры привязки к глифу diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7a0e3f39b..73ec524fa 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6127,6 +6127,8 @@ axis Y Axis Y-axel # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttext Area Text Områdestext +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mät text efter glyfgränser # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Visa alternativ för fäst mot glyf Hide Snap to Glyph Options Dölj alternativ för fäst mot glyf diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 67b958ebd..9768ae1dd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6060,6 +6060,8 @@ axis Y Axis Y ekseni # Align panel menu › Align to Glyph Bounds (#4231). Point Text Nokta Metni Area Text Alan Metni +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Yazıyı Glif Sınırlarına Göre Ölç # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glif'e Yapışma Seçeneklerini Göster Hide Snap to Glyph Options Glif'e Yapışma Seçeneklerini Gizle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67050653e..d41f86f00 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5701,6 +5701,8 @@ axis Y Axis Вісь Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точковий текст Area Text Текст в області +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Вимірювати текст за межами гліфа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показати параметри прив’язки до гліфа Hide Snap to Glyph Options Приховати параметри прив’язки до гліфа diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7c0279792..445a72000 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5702,6 +5702,8 @@ axis Y Axis Trục Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Chữ điểm Area Text Chữ vùng +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Đo chữ theo giới hạn glyph # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Hiện tùy chọn hút vào glyph Hide Snap to Glyph Options Ẩn tùy chọn hút vào glyph diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b7b8f6561..b8720d333 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5942,6 +5942,8 @@ axis Y Axis Y 轴 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 点文字 Area Text 区域文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 按字形边界度量文字 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 显示靠齐字符选项 Hide Snap to Glyph Options 隐藏靠齐字符选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 328cbb1f8..1aff8f69d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5739,6 +5739,8 @@ axis Y Axis Y 軸 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 點狀文字 Area Text 區域文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 依字符邊界測量文字 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 顯示靠齊字符選項 Hide Snap to Glyph Options 隱藏靠齊字符選項 diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index b1f8322fd..2596b218b 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -576,6 +576,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Scale Strokes & Effects"), true, ss) { set_pref(app, "scaleStrokes", !ss); } + // The panel's values and the bounding box measure point type by its glyphs' ink. + let glyphs = app.session.prefs.type_glyph_bounds; + if menu_item(ui, tl!("Measure Type by Glyph Bounds"), true, glyphs) { + set_pref(app, "typeGlyphBounds", !glyphs); + } ui.separator(); // What the panel transforms (Transform Object Only / Pattern Only / Both), one checked. let current = pattern_mode(app); diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index b5598689a..67cebc7cd 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -32,6 +32,26 @@ fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() { assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds")); } +/// The panel menu's Measure Type by Glyph Bounds is checked while `typeGlyphBounds` is on, and the +/// bounding box of selected point type then measures its glyphs without a change to the document. +#[test] +fn measure_type_by_glyph_bounds_is_checked_and_resizes_the_box() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 200, "size": 100, "text": "Hg"})).unwrap(); + assert!(texts(&mut app, menu).iter().any(|t| t == " Measure Type by Glyph Bounds")); + let em = app.selection_box().unwrap().rect; + let revision = app.session.active().unwrap().revision; + set_pref(&mut app, "typeGlyphBounds", true); + assert!(app.session.prefs.type_glyph_bounds); + assert!(texts(&mut app, menu).iter().any(|t| t == "✓ Measure Type by Glyph Bounds")); + assert_eq!(app.session.active().unwrap().revision, revision); + let glyphs = app.selection_box().unwrap().rect; + assert!(glyphs.height() < em.height() - 10.0, "{glyphs:?} inside {em:?}"); + set_pref(&mut app, "typeGlyphBounds", false); + assert_eq!(app.selection_box().unwrap().rect, em); +} + #[test] fn use_preview_bounds_shows_the_size_after_geometry_effects() { // A 100 × 50 rectangle without a stroke, with Offset Path 10: 120 × 70 as drawn. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2cb542c6b..b480106b3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -529,6 +529,10 @@ fields measure visual bounds. Align to Glyph Bounds: the Align panel flyout's Align to Glyph Bounds submenu shows and toggles the `alignGlyphBoundsPoint` (Point Text) and `alignGlyphBoundsArea` (Area Text) preferences (`prefs.set`), checked while on. +Measure Type by Glyph Bounds: the Transform panel menu's Measure Type by Glyph Bounds item shows and toggles the +`typeGlyphBounds` preference (`prefs.set`), checked while on; while it is on, the bounding box and the Transform +panel, Properties and Control bar fields measure point type selected on its own by its glyphs' ink. + Align To: the Align panel's Align To choice is saved with the preferences, and `ui.inspect` reports it as `ui.align_to` (`"selection"` or `"artboard"`; Align to Key Object stores no choice). The align buttons of the Align panel, the Control bar and the Properties panel run `object.align` with `to: "artboard"` when one object is selected, diff --git a/docs/mcp.md b/docs/mcp.md index 2618ebfa4..c33243f42 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3963,6 +3963,29 @@ an error that lists the choices of a `horizontal`, `vertical`, `to`, `axis` or ` (`object.reflect` also takes an angle and names the value given), and one for a `spacing` that is not a number; nothing moves. +## Glyph bounds for type + +The bounding box normally measures point type by its em box, which leaves room above and below the letters for +ascenders, descenders and capitals. With the preference `typeGlyphBounds` on (`prefs.set {key: "typeGlyphBounds", +value: true}`; also Preferences › Type › Glyph Bounds and the Transform panel menu's Measure Type by Glyph Bounds), the +bounding box and its handles, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, +`object.setBounds`, the Scale, Rotate and Reflect tools' reference point and the Arrange commands measure point type +selected on its own, horizontal or vertical, by the ink of its glyphs. The option is VectorCraft's own and is off by +default. + +A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its +font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt +font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is +measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that +draws nothing (no text, only spaces), area type, type on a path and type inside a selected group are measured as +before. Align and Distribute follow Align to Glyph Bounds, and snapping and clicks do not change. The preference is +not saved in documents. + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeGlyphBounds":true}}}} +{"name":"run_command","arguments":{"command":"object.setBounds","params":{"height":72,"proportional":true}}} +``` + ## Selection preferences The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17cc2849b..2140f5410 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1052,6 +1052,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). + - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds` and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. The Lights list, Add Light and Delete Light manage one to ten independent colored lights; ambient is shared and visibility remains approximate ([revolve.md](revolve.md)). From 49129c565b4ee1b6f359ef49f1461649d004a458 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 20:38:14 -0800 Subject: [PATCH 31/48] Export: exported objects keep the clips, masks and effects around them Export Selection, Export for Screens by selection or by layer, Asset Export and CSS Properties' rasterized pictures put the objects on a new plain layer. An object inside a clip group, or on a clipping layer, was written without its clip, in SVG, PNG and the other formats, and the opacity masks, effects, opacity and blend modes of the groups and layers that held it were dropped too. These exports now keep each object in the layers and groups that hold it, and crop the file to the bounds the renderer measures for that art (a clip group by its clip). Those layers are drawn even when they are hidden, template or non-printing layers, as the objects were before. A PSD of the export names its layers after the document's layers instead of one layer named Selection or after the asset. document.export with selectedOnly uses the same code: an object of a blend, envelope, repeat or intertwine is now written on its own instead of inside a blend of one object, and a Shaper group keeps its source art. A text export of the selection still holds only the selected type, not that of a type clipping path around it. --- crates/engine/src/cmd/assets.rs | 2 +- crates/engine/src/cmd/css.rs | 2 +- crates/engine/src/cmd/fileio.rs | 2 + crates/engine/src/cmd/fileio/commands.rs | 6 +- crates/engine/src/cmd/fileio/export.rs | 115 ++++-- .../engine/src/cmd/fileio/tests_selexport.rs | 375 ++++++++++++++++++ docs/mcp.md | 7 +- docs/target-app-parity.md | 2 + 8 files changed, 473 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_selexport.rs diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 8c225b877..31639519f 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> { "Assets", [], None, - "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included, with the clips of the groups and layers that hold it), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", has_doc, list ), diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index 1780cf2be..ed83a0141 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", + "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index b913720f1..04a443ed4 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -184,6 +184,8 @@ mod tests_batch; #[cfg(test)] mod tests_screens; #[cfg(test)] +mod tests_selexport; +#[cfg(test)] mod tests_text; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ce7a82d2b..81e8168d0 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index a004fad89..02dda6f0f 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -94,27 +94,63 @@ pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result<Cow<'a, Document return Err(bad("document.export", "select something to export (or turn off selectedOnly)")); } let mut d = Document::clone(&st.doc); - d.layers = st.doc.layers.iter().filter_map(|l| selected_part(l, &keep)).map(Arc::new).collect(); + d.layers = enclosed(&st.doc, &keep, false); Ok(Cow::Owned(d)) } -/// `n` with only the parts in `keep` (whole, with everything inside them), `None` when none is. -fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> { +/// The layers of `doc` with only the objects in `keep` (whole, with everything inside them), each +/// in the layers, groups and compound paths that hold it. Those keep what they apply to the object +/// on the canvas: their clipping paths, opacity masks, effects, opacity and blend modes. An object +/// of a blend, envelope, repeat or intertwine goes in on its own, without the art made from it. With +/// `shown`, the layers and groups that hold the objects are visible, and those layers print and are +/// not template layers. +fn enclosed(doc: &Document, keep: &HashSet<NodeId>, shown: bool) -> Vec<Arc<Node>> { + let holders: HashSet<NodeId> = keep.iter().filter_map(|id| doc.ancestry(*id)).flatten().filter(|id| !keep.contains(id)).collect(); + let mut out = vec![]; + for l in &doc.layers { + selected_part(l, keep, &holders, shown, &mut out); + } + out +} + +/// The parts of `n` in `keep` (whole, with everything inside them), added to `out`: `n` itself, or +/// `n` with only those parts when it is one of their `holders`, made visible, printable and not a +/// template with `shown`. +fn selected_part(n: &Arc<Node>, keep: &HashSet<NodeId>, holders: &HashSet<NodeId>, shown: bool, out: &mut Vec<Arc<Node>>) { if keep.contains(&n.id) { - return Some(n.clone()); + out.push(n.clone()); + return; + } + let Some(children) = n.children().filter(|_| holders.contains(&n.id)) else { return }; + let mut kept = vec![]; + for c in children { + selected_part(c, keep, holders, shown, &mut kept); + } + if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. }) { + out.extend(kept); + return; } - let children = n.children()?; - let mut kept: Vec<Arc<Node>> = children.iter().filter_map(|c| selected_part(c, keep).map(Arc::new)).collect(); if kept.is_empty() { - return None; + return; } - let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); - if let Some(first) = children.first().filter(|f| clip && kept.first().is_none_or(|k| k.id != f.id)) { + // A clipping group or layer keeps its clipping path; a Shaper group its source art, which + // draws nothing. + let first_stays = n.clips() || n.extras().shaper.is_some(); + if let Some(first) = children.first().filter(|f| first_stays && kept.first().is_none_or(|k| k.id != f.id)) { kept.insert(0, first.clone()); } - let mut out = n.clone(); - *out.children_mut()? = kept; - Some(out) + let mut part = Node::clone(n); + if let Some(ch) = part.children_mut() { + *ch = kept; + } + if shown { + part.visible = true; + if let NodeKind::Layer { template, printable, .. } = &mut part.kind { + *template = false; + *printable = true; + } + } + out.push(Arc::new(part)); } /// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left @@ -202,7 +238,7 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Va /// with `selectionOnly` the selected objects alone. fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> { if f.id == "txt" && bool_or(p, "selectionOnly", false) { - let doc = selection(s, cmd)?.0; + let doc = selection(s, cmd, true)?.0; let display_doc_title = super::has_own_title(&doc); return Ok((Cow::Owned(doc), display_doc_title)); } @@ -212,11 +248,21 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title)) } -/// The selected objects (not on template layers) alone on one layer, with one artboard: their -/// visual bounds. -fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> { +/// The selected objects (not on template layers) in the layers and groups that hold them +/// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a +/// text export holds the type of the selected objects and not that of a clipping path around them. +fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> { let ids = selected_artwork(s)?; - isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export")) + let doc = &s.doc()?.doc; + let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?; + if text { + let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect(); + } + d.layers = vec![Arc::new(layer)]; + } + Ok((d, bounds)) } /// The selected objects (their top-most selected ancestors) that are artwork: those on template @@ -231,25 +277,30 @@ pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> { Ok(ids.into_iter().filter(|id| !on_template(*id)).collect()) } -/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what -/// it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; symbol -/// instances are measured by their art), `None` when they have none. +/// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer +/// measures for what it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; +/// symbol instances are measured by their art), with what the layers and groups that hold them +/// apply (a clipping group or layer is measured by its clip), `None` when they have none. pub(crate) fn export_crop(doc: &Document, ids: &[NodeId]) -> Option<vectorcraft_geom::Rect> { - let symbols = doc.symbol_boxes(); - ids.iter().filter_map(|id| vectorcraft_render::effect_bounds(&symbols, doc.node(*id)?)).reduce(|a, b| a.union(b)) + isolated_art(doc, ids).1 } -/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard -/// named `name`: their [`export_crop`] (`None` when they have none). +/// The layers of [`isolated`] and their crop. +fn isolated_art(doc: &Document, ids: &[NodeId]) -> (Vec<Arc<Node>>, Option<vectorcraft_geom::Rect>) { + let keep: HashSet<NodeId> = ids.iter().copied().collect(); + let layers = enclosed(doc, &keep, true); + let symbols = doc.symbol_boxes(); + let bounds = layers.iter().filter_map(|l| vectorcraft_render::effect_bounds(&symbols, l)).reduce(|a, b| a.union(b)); + (layers, bounds) +} + +/// Objects `ids` of `doc` alone, each in the layers and groups that hold it ([`enclosed`], made +/// visible), with one artboard named `name`: their [`export_crop`] (`None` when they have none). pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> { - let bounds = export_crop(doc, ids)?; - let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect(); + let (layers, bounds) = isolated_art(doc, ids); + let bounds = bounds?; let mut d = single_artboard(doc, bounds, name); - let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0)); - if let Some(ch) = layer.children_mut() { - *ch = nodes; - } - d.layers = vec![Arc::new(layer)]; + d.layers = layers; Some((d, bounds)) } @@ -265,7 +316,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> { let f = writable(C, str_param(p, "format"), path)?; let expanded = expand_presets(s, C, p)?; let p = with_black_output(s, &expanded); - let (d, bounds) = selection(s, C)?; + let (d, bounds) = selection(s, C, f.id == "txt")?; // The encoder's warnings (options accepted but not applied, features approximated or left // out), as document.export reports them. let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?; diff --git a/crates/engine/src/cmd/fileio/tests_selexport.rs b/crates/engine/src/cmd/fileio/tests_selexport.rs new file mode 100644 index 000000000..57db4891a --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_selexport.rs @@ -0,0 +1,375 @@ +//! Exports of objects alone (Export Selection, Export for Screens by selection or by layer, Asset +//! Export, a CSS rule's picture and `selectedOnly`) draw the objects as the canvas draws them: with +//! the clipping paths, opacity masks, effects, opacity and blend modes of the layers and groups that +//! hold them. + +use serde_json::{Value, json}; +use vectorcraft_geom::Rect; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap(); + s +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> u64 { + v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}")) +} + +/// A red 300 pt square at (100, 100) clipped by a 200 pt circle at (150, 150): (square, clip group). +fn clipped(s: &mut Session) -> (u64, u64) { + let square = id(&run(s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300}))); + run(s, "paint.setFill", json!({"color": "#ff0000"})); + let circle = id(&run(s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200}))); + let group = id(&run(s, "object.clippingMask.make", json!({"ids": [circle, square]}))); + (square, group) +} + +fn b64(v: &Value) -> Vec<u8> { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap() +} + +fn rect_of(v: &Value) -> Rect { + let n = |i: usize| v[i].as_f64().unwrap_or_else(|| panic!("bad bounds {v}")); + Rect::new(n(0), n(1), n(0) + n(2), n(1) + n(3)) +} + +/// What the canvas draws of the whole document over `r`, at 1 px per point. +fn canvas(s: &Session, r: Rect) -> image::RgbaImage { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, r, 1.0, false); + image::load_from_memory(&img.to_png().unwrap()).unwrap().to_rgba8() +} + +/// The pixels of `png` that differ from `want` by more than 64 in a channel (anti-aliased edges +/// differ by less), and every pixel when the sizes differ. +fn differing(png: &[u8], want: &image::RgbaImage) -> usize { + let got = image::load_from_memory(png).unwrap().to_rgba8(); + if got.dimensions() != want.dimensions() { + return (want.width() * want.height()) as usize; + } + got.pixels().zip(want.pixels()).filter(|(a, b)| a.0.iter().zip(b.0.iter()).any(|(x, y)| x.abs_diff(*y) > 64)).count() +} + +/// Export Selection of what is selected, as a PNG, matches the canvas over its crop: fewer than 200 +/// pixels differ (anti-aliased edges), or 200 times `edges` with [`selection_png_matches_canvas_with`]. +fn selection_png_matches_canvas(s: &mut Session, want: Rect, what: &str) { + selection_png_matches_canvas_with(s, want, what, 1); +} + +fn selection_png_matches_canvas_with(s: &mut Session, want: Rect, what: &str, edges: usize) { + let r = run(s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let got = rect_of(&r["bounds"]); + assert!((got.x0 - want.x0).abs() < 1.5 && (got.y0 - want.y0).abs() < 1.5, "{what}: crop {got:?}, want {want:?}"); + assert!((got.width() - want.width()).abs() < 3.0 && (got.height() - want.height()).abs() < 3.0, "{what}: crop {got:?}, want {want:?}"); + let off = differing(&b64(&r), &canvas(s, got)); + assert!(off < 200 * edges, "{what}: {off} pixels differ from the canvas"); +} + +#[test] +fn export_selection_of_a_clipped_object_keeps_its_clip() { + let mut s = session(); + let (square, _) = clipped(&mut s); + run(&mut s, "select.set", json!({"ids": [square]})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "the square alone"); + let svg = b64(&run(&mut s, "document.exportSelection", json!({"format": "svg"}))); + let text = String::from_utf8_lossy(&svg); + assert!(text.contains("<clipPath") && text.contains("clip-path"), "the SVG clips the square: {text}"); + // Opened again, the SVG draws what the canvas draws. + let mut t = Session::new(); + run(&mut t, "document.open", json!({"name": "sel.svg", "dataBase64": vectorcraft_format::base64_encode(&svg)})); + let board = t.doc().unwrap().doc.artboards[0].rect; + let reopened = canvas(&t, board); + let mut png = vec![]; + reopened.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let off = differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))); + assert!(off < 200, "the reopened SVG: {off} pixels differ from the canvas"); +} + +#[test] +fn export_selection_of_a_clipping_layer_keeps_the_layer_clip() { + let mut s = session(); + let layer = id(&run(&mut s, "layer.new", json!({"name": "L"}))); + run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200})); + run(&mut s, "layer.clippingMask.toggle", json!({"id": layer})); + run(&mut s, "select.all", json!({})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "a clipping layer's objects"); +} + +#[test] +fn export_selection_keeps_the_opacity_effects_and_mask_of_the_groups_around_it() { + let mut s = session(); + let (square, group) = clipped(&mut s); + let other = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40}))); + let outer = id(&run(&mut s, "object.group", json!({"ids": [group, other]}))); + run(&mut s, "transparency.set", json!({"ids": [outer], "opacity": 50})); + run(&mut s, "select.set", json!({"ids": [square]})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "inside a group at 50%"); + // A drop shadow on the clip group reaches past its clip. + run(&mut s, "effect.apply", json!({"ids": [group], "effect": "stylize.dropShadow"})); + let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let got = rect_of(&r["bounds"]); + assert!(got.x1 > 352.0 && got.y1 > 352.0, "the crop takes in the clip group's shadow: {got:?}"); + assert!(differing(&b64(&r), &canvas(&s, got)) < 200, "with the shadow"); + run(&mut s, "edit.undo", json!({})); + // An opacity mask on the clip group. + let mask = id(&run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300}))); + run( + &mut s, + "paint.setFill", + json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}), + ); + run(&mut s, "transparency.makeOpacityMask", json!({"ids": [mask, group]})); + run(&mut s, "select.set", json!({"ids": [square]})); + let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let got = rect_of(&r["bounds"]); + assert!(differing(&b64(&r), &canvas(&s, got)) < 200, "with an opacity mask on the clip group"); +} + +#[test] +fn export_selection_across_clip_groups_and_of_whole_ones() { + let mut s = session(); + let (a, ga) = clipped(&mut s); + let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 90, "height": 90}))); + let c = id(&run(&mut s, "shape.rectangle", json!({"x": 420, "y": 420, "width": 50, "height": 50}))); + let gb = id(&run(&mut s, "object.clippingMask.make", json!({"ids": [c, b]}))); + // A member of each clip group: each keeps its own clip. + run(&mut s, "select.set", json!({"ids": [a, b]})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 470.0, 470.0), "a member of each of two clip groups"); + // The clip groups themselves. + run(&mut s, "select.set", json!({"ids": [ga, gb]})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 470.0, 470.0), "two clip groups"); + // Nested clip groups: the inner group's member is clipped by both, cropped to the outer clip. + let strip = id(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 200, "width": 500, "height": 100}))); + run(&mut s, "object.clippingMask.make", json!({"ids": [strip, ga]})); + run(&mut s, "select.set", json!({"ids": [a]})); + selection_png_matches_canvas(&mut s, Rect::new(0.0, 200.0, 500.0, 300.0), "a member of nested clip groups"); +} + +#[test] +fn a_selected_clipping_path_and_a_blend_object_export_on_their_own() { + let mut s = session(); + let (square, group) = clipped(&mut s); + let circle = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id.0; + assert_ne!(circle, square); + // The clipping path alone: the clip group's other art stays out. + run(&mut s, "select.set", json!({"ids": [circle]})); + let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (200, 200), "cropped to the clipping path"); + assert!(!img.pixels().any(|p| p[0] > 200 && p[1] < 80 && p[3] > 0), "the clipped square is left out"); + // An object of a blend goes in on its own, without the blend's steps. + let mut s = session(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let a = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 60, "height": 60}))); + let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 60, "height": 60}))); + run(&mut s, "select.set", json!({"ids": [a, b]})); + run(&mut s, "object.blend.make", json!({})); + run(&mut s, "select.set", json!({"ids": [a]})); + let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let got = rect_of(&r["bounds"]); + assert!(got.x1 < 90.0 && got.y1 < 90.0, "the blend object's own bounds: {got:?}"); + let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8(); + let centre = img.get_pixel(img.width() / 2, img.height() / 2); + assert!(centre[0] > 200 && centre[3] > 200, "the object is drawn: {centre:?}"); +} + +#[test] +fn a_css_picture_of_a_clipped_object_is_clipped() { + let mut s = session(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let star = id(&run(&mut s, "shape.star", json!({"cx": 250, "cy": 250, "radius1": 150, "radius2": 70, "points": 5}))); + let circle = id(&run(&mut s, "shape.ellipse", json!({"x": 200, "y": 200, "width": 100, "height": 100}))); + run(&mut s, "object.clippingMask.make", json!({"ids": [circle, star]})); + let r = run(&mut s, "css.export", json!({"ids": [star], "rasterize": true, "unnamed": true})); + let image = r["images"].as_array().and_then(|i| i.first()).unwrap_or_else(|| panic!("no picture: {r}")); + let png = b64(image); + let size = image::load_from_memory(&png).unwrap().to_rgba8().dimensions(); + assert_eq!(size, (100, 100), "the picture is cropped to the clip"); + assert!(differing(&png, &canvas(&s, Rect::new(200.0, 200.0, 300.0, 300.0))) < 100, "the picture matches the canvas"); +} + +#[test] +fn an_asset_on_a_hidden_layer_is_drawn_clipped() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let (square, _) = clipped(&mut s); + let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap(); + run(&mut s, "layer.setProps", json!({"id": layer, "visible": false})); + let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]})); + let img = image::load_from_memory(&b64(&r["files"][0])).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (200, 200), "cropped to the clip"); + let (centre, corner) = (img.get_pixel(100, 100), img.get_pixel(3, 3)); + assert!(centre[0] > 200 && centre[3] > 200, "the square is drawn: {centre:?}"); + assert_eq!(corner[3], 0, "outside the circle stays clear: {corner:?}"); +} + +#[test] +fn export_for_screens_and_assets_by_selection_keep_the_clip() { + let mut s = session(); + let (square, _) = clipped(&mut s); + run(&mut s, "select.set", json!({"ids": [square]})); + let r = run(&mut s, "document.exportForScreens", json!({"selection": "one", "formats": [{"format": "png", "scale": 1}, {"format": "svg"}]})); + let want = canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0)); + let png = r["files"].as_array().unwrap().iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(".png"))).unwrap(); + assert!(differing(&b64(png), &want) < 200, "Export for Screens by selection: {}", png["name"]); + let svg = r["files"].as_array().unwrap().iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(".svg"))).unwrap(); + let text = String::from_utf8(b64(svg)).unwrap(); + assert!(text.contains("<clipPath"), "the SVG clips the square: {text}"); + let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap(); + let list = run(&mut s, "assets.list", json!({})); + let bounds = rect_of(&list["assets"][0]["bounds"]); + assert!((bounds.width() - 200.0).abs() < 3.0, "the asset's crop is its clip: {bounds:?}"); + let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]})); + assert!(differing(&b64(&r["files"][0]), &want) < 200, "Asset Export: {}", r["files"][0]["name"]); +} + +/// The red pixels of `png`. +fn red(png: &[u8]) -> usize { + image::load_from_memory(png).unwrap().to_rgba8().pixels().filter(|p| p[0] > 200 && p[1] < 80 && p[3] > 200).count() +} + +#[test] +fn a_selection_on_a_non_printing_layer_exports_to_pdf() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let (square, _) = clipped(&mut s); + run(&mut s, "layer.setProps", json!({"id": layer, "printable": false})); + run(&mut s, "select.set", json!({"ids": [square]})); + let pdf = b64(&run(&mut s, "document.exportSelection", json!({"format": "pdf", "createLayers": false, "preserveEditing": false}))); + let mut t = Session::new(); + run(&mut t, "document.open", json!({"name": "sel.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf)})); + let page = t.doc().unwrap().doc.artboards[0].rect; + let mut png = vec![]; + canvas(&t, page).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert!(red(&png) > 20_000, "the PDF draws the square: {} red pixels", red(&png)); + assert!(differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))) < 300, "the PDF draws what the canvas draws"); +} + +#[test] +fn export_for_screens_of_layers_draws_them_as_the_canvas_does() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id.0; + clipped(&mut s); + // A layer at 50% opacity: its art is drawn at 50%. + run(&mut s, "transparency.set", json!({"ids": [layer], "opacity": 50})); + let r = run(&mut s, "document.exportForScreens", json!({"layers": [layer], "formats": [{"format": "png", "scale": 1}]})); + let png = b64(&r["files"][0]); + assert!(differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))) < 200, "the layer's opacity applies"); + // A template layer's art is still drawn. + run(&mut s, "transparency.set", json!({"ids": [layer], "opacity": 100})); + run(&mut s, "layer.setProps", json!({"id": layer, "template": true})); + let r = run(&mut s, "document.exportForScreens", json!({"layers": [layer], "formats": [{"format": "png", "scale": 1}, {"format": "svg"}]})); + let files = r["files"].as_array().unwrap(); + let file = |ext: &str| b64(files.iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(ext))).unwrap()); + assert!(red(&file(".png")) > 20_000, "the template layer's art is drawn: {} red pixels", red(&file(".png"))); + assert!(String::from_utf8(file(".svg")).unwrap().contains("<clipPath"), "the SVG holds the clipped art"); +} + +#[test] +fn a_text_export_of_the_selection_leaves_out_a_type_clipping_path() { + let mut s = session(); + // The front object, the type "Clip", becomes the clipping path. + let inner = id(&run(&mut s, "text.create", json!({"x": 100, "y": 110, "text": "Inner"}))); + let clip = id(&run(&mut s, "text.create", json!({"x": 100, "y": 100, "text": "Clip"}))); + run(&mut s, "object.clippingMask.make", json!({"ids": [clip, inner]})); + let mut first = None; + s.doc().unwrap().doc.layers[0].walk(&mut |n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. }) { + first = n.children().and_then(|c| c.first()).map(|c| c.id.0); + } + }); + assert_eq!(first, Some(clip), "the type is the clipping path"); + run(&mut s, "select.set", json!({"ids": [inner]})); + let txt = b64(&run(&mut s, "document.export", json!({"format": "txt", "selectionOnly": true}))); + assert_eq!(String::from_utf8(txt).unwrap(), "Inner\n"); + let txt = b64(&run(&mut s, "document.exportSelection", json!({"format": "txt"}))); + assert_eq!(String::from_utf8(txt).unwrap(), "Inner\n"); +} + +#[test] +fn deep_clip_groups_symbols_and_large_selections_keep_their_clips() { + // Five clip groups inside each other, each clip a little smaller. + let mut s = session(); + let (square, mut group) = clipped(&mut s); + for i in 1..5 { + let inset = 10.0 * f64::from(i); + let clip = id(&run(&mut s, "shape.rectangle", json!({"x": 150.0 + inset, "y": 150.0 + inset, "width": 200.0 - 2.0 * inset, "height": 400}))); + group = id(&run(&mut s, "object.clippingMask.make", json!({"ids": [clip, group]}))); + } + run(&mut s, "select.set", json!({"ids": [square]})); + selection_png_matches_canvas(&mut s, Rect::new(190.0, 190.0, 310.0, 590.0), "a member of five nested clip groups"); + + // A symbol instance in a clip group. + let mut s = session(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let art = id(&run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300}))); + run(&mut s, "select.set", json!({"ids": [art]})); + let instance = id(&run(&mut s, "symbol.new", json!({"name": "Red"}))); + let circle = id(&run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200}))); + run(&mut s, "object.clippingMask.make", json!({"ids": [circle, instance]})); + run(&mut s, "select.set", json!({"ids": [instance]})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "a symbol instance in a clip group"); + + // 200 clip groups, a member of each selected. + let mut s = session(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let mut members = vec![]; + for i in 0..200 { + let (x, y) = (f64::from(i % 20) * 25.0, f64::from(i / 20) * 25.0); + let m = id(&run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": 20, "height": 20}))); + let c = id(&run(&mut s, "shape.ellipse", json!({"x": x, "y": y, "width": 20, "height": 20}))); + run(&mut s, "object.clippingMask.make", json!({"ids": [c, m]})); + members.push(m); + } + run(&mut s, "select.set", json!({"ids": members})); + // The circles' anti-aliased edges differ by up to about 30 pixels each; without their clips the + // squares' corners differ by 86 pixels each. + selection_png_matches_canvas_with(&mut s, Rect::new(0.0, 0.0, 495.0, 245.0), "members of 200 clip groups", 40); +} + +#[test] +fn an_asset_in_a_hidden_or_locked_clip_group_is_drawn_clipped() { + for change in ["object.hide", "object.lock"] { + let mut s = session(); + let (square, group) = clipped(&mut s); + let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap(); + let want = canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0)); + run(&mut s, "select.set", json!({"ids": [group]})); + run(&mut s, change, json!({})); + let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]})); + assert!(differing(&b64(&r["files"][0]), &want) < 200, "{change}: the asset draws as the canvas did"); + } +} + +#[test] +fn selected_only_keeps_holders_and_takes_a_blend_object_alone() { + let mut s = session(); + let (square, _) = clipped(&mut s); + run(&mut s, "select.set", json!({"ids": [square]})); + let r = run(&mut s, "document.export", json!({"format": "png", "scale": 1, "selectedOnly": true})); + let board = s.doc().unwrap().doc.artboards[0].rect; + assert!(differing(&b64(&r), &canvas(&s, board)) < 200, "selectedOnly keeps the clip"); + // An object of a blend is written on its own. + let mut s = session(); + let a = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 60, "height": 60}))); + let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 60, "height": 60}))); + run(&mut s, "select.set", json!({"ids": [a, b]})); + run(&mut s, "object.blend.make", json!({})); + run(&mut s, "select.set", json!({"ids": [a]})); + let svg = run(&mut s, "document.serialize", json!({"format": "svg", "selectedOnly": true}))["text"].as_str().unwrap().to_string(); + let mut t = Session::new(); + run(&mut t, "document.open", json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})); + let mut paths = 0; + for l in &t.doc().unwrap().doc.layers { + l.walk(&mut |n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }))); + } + assert_eq!(paths, 1, "the blend object alone, without the steps: {svg}"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 013fd3d14..03741f788 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5177,7 +5177,12 @@ fallback font…), each note once; the export still writes or returns every file Batch exports (#1935): - **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards - (`assets`, `fullDocument` as before). + (`assets`, `fullDocument` as before). A selected object, a layer's object or an asset's object stays in the layers + and groups that hold it, so their clipping paths, opacity masks, effects, opacity and blend modes apply to it as on + the canvas, and the file is cropped to the bounds the renderer measures for that art (a clip group by its clip); an + object of a blend, envelope, repeat or intertwine is drawn on its own. Those layers are drawn even when they are + hidden, template or non-printing layers, and a PSD of the file names its layers after them. + `document.exportSelection` and the pictures of `css.export {rasterize: true}` draw the selected objects the same way. - **Names:** `name` is a pattern for every file: `<filename>` (the document's file name without its extension, or its title before it is saved; the Full Document file and the `.zip` use the same name), `<name>` (the artboard, asset, layer or object), `<index>`, `####` (a counter zero-padded to the run of `#`, from `counterStart`; `counterPer: "item"` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 198fdc28e..fd2771491 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -596,6 +596,8 @@ since 2026-10-01. - **Effects in Export Selection and asset crops:** File › Export Selection, Asset Export, Export for Screens' Assets tab and CSS Properties' rasterized art crop each object to the bounds the renderer measures for what it draws: drop shadows, glows, geometry effects such as Offset Path, a group's own effects and the effects in a symbol's art. A brushed path is cropped to its visual bounds, and a Shaper group is cropped to the art it shows. `assets.list` reports the same crop. Engine tests export a square with each effect, a group with a shadow and an asset with a shadow, and check that the effect is inside the image. Public reference: a [Community Expert answer](https://community.adobe.com/t5/illustrator-discussions/exporting-assets-adds-transparent-borders/m-p/10708713/highlight/true) traces the extra area of exported assets to the raster effects' area (Document Raster Effects Settings › Add Around Object). +- **Clips and masks around exported selections:** File › Export Selection, Export for Screens by selection or by layer, Asset Export and CSS Properties' rasterized art draw each object in the layers and groups that hold it. A clip group's or clipping layer's clip, an opacity mask, and a group's or layer's effects, opacity and blend mode apply as on the canvas. The crop is the bounds the renderer measures for that art, a clip group by its clip. These are VectorCraft's own rules. An object of a blend, envelope, repeat or intertwine is exported on its own, and a text export of the selection holds only the selected type. Engine tests compare the PNG exports with the canvas over the same area, reopen the SVG and PDF exports, and cover nested clip groups, symbol instances, 200 clip groups and assets in hidden or locked groups. + - **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them. - **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Flatten Transparency puts its white under the finished art the same way. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`, `tests::knockout::no_blend_mode_reads_the_background`, `tests::cmykflatten::every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk`. From 9ccea9cfcb31f1412bb9e4b03df227af7a99cb22 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 21:24:13 -0800 Subject: [PATCH 32/48] Commands: engine.apiReference returns the command API reference in Markdown engine.apiReference {} -> {markdown} writes a reference from the built-in commands' parameter schemas each time it runs: a section per command that has a schema, in registry order, with its label, menu path, API levels, what it writes, cost, summary and return, and a table of its parameters (type, range with included or excluded ends, unit, JSON or worked-out default, required, levels and description; object members as dotted rows). Commands without a schema, plug-in commands included, are left out. Schema text is escaped so it stays plain text in its cell, control characters become spaces, and a reference past 1 MiB is an error rather than a cut-short text. The command needs no document. MCP serves the same text as the fixed resource vectorcraft://commands/reference (text/markdown; charset=utf-8); the other resources still read as JSON. The command's own schema raises the schema API level to 2. Its label and its size error have rows in every catalog that needs them. Docs: docs/mcp.md, docs/target-app-parity.md. --- apps/vectorcraft-cli/tests/it/cli.rs | 2 +- crates/engine/src/cmd.rs | 3 + crates/engine/src/cmd/apiref.rs | 461 +++++++++++++++++++++++++++ crates/engine/src/schemas.rs | 10 +- crates/mcp/src/resources.rs | 76 ++++- crates/mcp/src/server.rs | 4 +- crates/mcp/src/tests.rs | 2 +- crates/mcp/tests/protocol_props.rs | 12 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/mcp.md | 31 +- docs/target-app-parity.md | 1 + 37 files changed, 635 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/cmd/apiref.rs diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index f2600d9b4..5bdd2bf63 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -597,7 +597,7 @@ fn exercise_mcp_core( assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}"); mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}})); - assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3)); + assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 4)); mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}})); assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4)); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a68703f11..7c1c9eea4 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -2,6 +2,7 @@ pub mod actionset; mod addons; +mod apiref; pub(crate) mod appearance; mod assets; mod attributes; @@ -384,6 +385,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(actionset::specs()); v.extend(importmatches::specs()); v.extend(addons::specs()); + // Last, so the reference it writes ends with its own section. + v.extend(apiref::specs()); v }) } diff --git a/crates/engine/src/cmd/apiref.rs b/crates/engine/src/cmd/apiref.rs new file mode 100644 index 000000000..54e07a1c8 --- /dev/null +++ b/crates/engine/src/cmd/apiref.rs @@ -0,0 +1,461 @@ +//! `engine.apiReference`: the command API reference in Markdown, written when it is asked for from +//! the parameter schemas of the built-in commands ([`crate::schemas`]). Each command that has a +//! schema gets a section, in registry order, with its parameters in the schema's order; a command +//! without a schema, a plug-in's included, is left out. The text depends only on the registry, so +//! every call in one build returns the same bytes. +//! +//! Text from a schema is escaped before it goes into a heading, a list item or a table cell: the +//! Markdown punctuation characters get a backslash (CommonMark 0.31.2, backslash escapes, +//! <https://spec.commonmark.org/0.31.2/#backslash-escapes>), so a description shows as plain text and +//! cannot end a table cell or make a link, an image or HTML; line breaks, tabs and other control +//! characters become spaces. + +use serde_json::{Value, json}; +use vectorcraft_apischema::{Bound, MAX_DEPTH, MAX_ID, Omitted, Param, ParamSchema, Type, Writes}; + +use super::{CommandSpec, Result, Session, always, cmd, command_specs}; +use crate::EngineError; + +const ID: &str = "engine.apiReference"; + +/// The largest reference the command returns, in bytes of UTF-8. +pub const MAX_BYTES: usize = 1 << 20; + +static REFERENCE: ParamSchema = ParamSchema::new( + &[], + "the command API reference in Markdown, written from the built-in commands' parameter schemas: a section for each command that has one, in registry order", +) +.returns("{markdown: string}") +.writes(Writes::Nothing) +.since(2); + +pub fn specs() -> Vec<CommandSpec> { + vec![cmd!( + query "engine.apiReference", + "Command API Reference", + [], + None, + "{} the command API reference in Markdown, written from the built-in commands' parameter schemas: a section for each command that has one, in registry order → {markdown: string}", + always, + api_reference, + schema: &REFERENCE + )] +} + +fn api_reference(_: &mut Session, _: &Value) -> Result<Value> { + Ok(json!({ "markdown": reference(command_specs())? })) +} + +const HEADER: &str = "# VectorCraft command API reference\n\nThis reference lists only the commands that have a parameter schema, in registry order. `engine.commands` lists every command, with its params text and whether it can run now.\n"; + +/// The reference for the commands of `specs` that have a schema, or an error when it would be +/// larger than [`MAX_BYTES`]; a reference that is too large is never cut short. +pub(crate) fn reference(specs: &[CommandSpec]) -> Result<String> { + let mut out = String::from(HEADER); + let mut sections = 0; + for spec in specs { + let Some(schema) = spec.schema else { continue }; + let text = section(spec, schema); + if out.len() + text.len() > MAX_BYTES { + return Err(EngineError::Other(format!("`{ID}`: the reference would be larger than {MAX_BYTES} bytes"))); + } + out.push_str(&text); + sections += 1; + } + if sections == 0 { + out.push_str("\nNo commands have parameter schemas.\n"); + } + Ok(out) +} + +/// One command's section: its heading, a list of what it is, and its parameters. +fn section(spec: &CommandSpec, schema: &ParamSchema) -> String { + let menu = if spec.menu.is_empty() { "None".to_string() } else { spec.menu.iter().map(|m| escape(m)).collect::<Vec<_>>().join(" › ") }; + let mut s = format!("\n## {}\n\n", escape(spec.id)); + s.push_str(&format!("- Label: {}\n", escape(spec.label))); + s.push_str(&format!("- Menu: {menu}\n")); + s.push_str(&format!("- API level: {}\n", schema.since)); + s.push_str(&format!("- Deprecated: {}\n", level_text(schema.deprecated))); + s.push_str(&format!("- Writes: {}\n", writes_text(schema.writes))); + s.push_str(&format!("- Cost: {}\n", schema.cost)); + s.push_str(&format!("- Summary: {}\n", text_or_none(schema.summary))); + s.push_str(&format!("- Returns: {}\n\n", text_or_none(schema.returns))); + if schema.params.is_empty() { + s.push_str("No parameters.\n"); + return s; + } + s.push_str("| Parameter | Type | Range | Unit | Default | Required | Since | Deprecated | Description |\n"); + s.push_str("|---|---|---|---|---|---|---|---|---|\n"); + rows("", schema.params, 0, &mut s); + s +} + +/// A table row for each of `params`, and after each one the rows of the members of an object it +/// holds: `edits[].name` for an object in a list, `options.size` for an object. +fn rows(prefix: &str, params: &[Param], depth: usize, out: &mut String) { + for p in params { + let name = if prefix.is_empty() { p.name.to_string() } else { format!("{prefix}.{}", p.name) }; + let (range, unit) = range_and_unit(&p.ty); + out.push_str(&format!( + "| {} | {} | {range} | {unit} | {} | {} | {} | {} | {} |\n", + escape(&name), + type_text(&p.ty, depth), + default_text(p.omitted), + if p.required { "Yes" } else { "No" }, + p.since, + level_text(p.deprecated), + description(p), + )); + if depth < MAX_DEPTH + && let Some((members, path)) = members(&p.ty, name, depth) + { + rows(&path, members, depth + 1, out); + } + } +} + +/// The parameters of the object `ty` holds, directly or as the items of lists, with the path of +/// its members' prefix (`name`, `name[]`, `name[][]`). +fn members(ty: &Type, mut path: String, depth: usize) -> Option<(&'static [Param], String)> { + let mut ty = ty; + for _ in depth..MAX_DEPTH { + match ty { + Type::Object(params) => return Some((params, path)), + Type::Array(item) => { + path.push_str("[]"); + ty = item; + } + _ => return None, + } + } + None +} + +fn type_text(ty: &Type, depth: usize) -> String { + match ty { + Type::Number { .. } => "number".into(), + Type::Integer { .. } => "integer".into(), + Type::Boolean => "boolean".into(), + Type::String => "string".into(), + Type::Enum(names) => format!("one of {}", names.iter().map(|n| format!("\"{}\"", escape(n))).collect::<Vec<_>>().join(", ")), + Type::Id => "object id (integer)".into(), + Type::Ids => "list of object ids".into(), + Type::Color => escape("color (a string, [r, g, b] or an object)"), + Type::Point => escape("point ([x, y], two numbers)"), + Type::Object(_) => "object".into(), + Type::Array(_) if depth >= MAX_DEPTH => "list".into(), + Type::Array(item) => format!("list of {}", type_text(item, depth + 1)), + } +} + +/// The range and unit cells of a value of type `ty`. +fn range_and_unit(ty: &Type) -> (String, String) { + let ends = |low: Option<String>, high: Option<String>| { + let both: Vec<String> = low.into_iter().chain(high).collect(); + if both.is_empty() { "None".to_string() } else { both.join(", ") } + }; + match ty { + Type::Number { min, max, unit } => { + let low = min.map(|b| match b { + Bound::Inclusive(a) => format!("at least {a}"), + Bound::Exclusive(a) => format!("more than {a}"), + }); + let high = max.map(|b| match b { + Bound::Inclusive(a) => format!("at most {a}"), + Bound::Exclusive(a) => format!("less than {a}"), + }); + (ends(low, high), unit.map_or("None".to_string(), escape)) + } + Type::Integer { min, max } => (ends(min.map(|a| format!("at least {a}")), max.map(|a| format!("at most {a}"))), "None".into()), + Type::Id => (format!("at least 0, at most {MAX_ID}"), "None".into()), + _ => ("None".into(), "None".into()), + } +} + +fn default_text(omitted: Omitted) -> String { + match omitted { + Omitted::Nothing => "None".into(), + Omitted::Value(json) => format!("{} (JSON)", escape(json)), + Omitted::Note(note) => format!("{} (worked out by the command)", escape(note)), + } +} + +/// The parameter's doc text, and what the schema says about how its value is checked. +fn description(p: &Param) -> String { + let mut parts = Vec::new(); + if !p.doc.is_empty() { + parts.push(escape(p.doc)); + } + if p.null_omitted { + parts.push("null reads as omitted".to_string()); + } + if p.lenient { + parts.push("lenient (a value of another type or out of range is passed to the command, not refused)".to_string()); + } + if parts.is_empty() { "None".to_string() } else { parts.join("; ") } +} + +fn level_text(level: Option<u32>) -> String { + level.map_or("None".to_string(), |l| format!("API level {l}")) +} + +fn writes_text(writes: Writes) -> &'static str { + match writes { + Writes::Nothing => "nothing", + Writes::Session => "session", + Writes::Preferences => "preferences", + Writes::Document => "document", + } +} + +fn text_or_none(text: &str) -> String { + if text.is_empty() { "None".to_string() } else { escape(text) } +} + +/// The characters [`escape`] puts a backslash before. +const ESCAPED: &[char] = &['\\', '|', '[', ']', '<', '>', '`', '*', '_', '!', '&', '#', '~', ':', '@']; + +/// `text` as plain Markdown text on one line: each of [`ESCAPED`] with a backslash before it, the +/// `.` after `www` too (so no autolink starts), and each control character as a space. +pub(crate) fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + if c.is_control() { + out.push(' '); + continue; + } + let after_www = out.len().checked_sub(3).and_then(|at| out.as_bytes().get(at..)).is_some_and(|b| b.eq_ignore_ascii_case(b"www")); + if ESCAPED.contains(&c) || (c == '.' && after_www) { + out.push('\\'); + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(_: &mut Session, _: &Value) -> Result<Value> { + Ok(Value::Null) + } + + fn spec(id: &'static str, menu: &'static [&'static str], schema: Option<&'static ParamSchema>) -> CommandSpec { + CommandSpec { id, label: "Label", menu, shortcut: None, params: "", enabled: always, run, journal: false, schema } + } + + /// The cells of a table row as a GFM table reads them: split at each `|` that has no backslash + /// before it, with the outer pipes and the spaces around each cell taken off. + fn cells(row: &str) -> Vec<String> { + let inner = row.strip_prefix('|').and_then(|r| r.strip_suffix('|')).unwrap_or_else(|| panic!("not a table row: {row}")); + let (mut out, mut cell, mut chars) = (Vec::new(), String::new(), inner.chars()); + while let Some(c) = chars.next() { + match c { + '\\' => { + cell.push(c); + cell.extend(chars.next()); + } + '|' => out.push(std::mem::take(&mut cell).trim().to_string()), + _ => cell.push(c), + } + } + out.push(cell.trim().to_string()); + out + } + + /// What `text` shows as: each escaped character as itself. Panics on a Markdown punctuation + /// character without its backslash, or a backslash before a character that needs none. + fn literal(text: &str) -> String { + let (mut out, mut chars) = (String::new(), text.chars()); + while let Some(c) = chars.next() { + assert!(!ESCAPED.contains(&c) || c == '\\', "`{c}` is not escaped in {text}"); + if c == '\\' { + let next = chars.next().unwrap_or_else(|| panic!("a backslash ends {text}")); + assert!(ESCAPED.contains(&next) || next == '.', "`\\{next}` in {text}"); + out.push(next); + } else { + out.push(c); + } + } + out + } + + /// The shown cells of the row of parameter `name`. + fn row(markdown: &str, name: &str) -> Vec<String> { + let found = markdown.lines().filter(|l| l.starts_with('|')).find(|l| cells(l).first().map(|c| literal(c)).as_deref() == Some(name)); + let row = found.unwrap_or_else(|| panic!("no row for {name} in\n{markdown}")); + let cells = cells(row); + assert_eq!(cells.len(), 9, "{row}"); + cells.iter().map(|c| literal(c)).collect() + } + + /// The text after `- {label}: ` in the section's list. + fn item(markdown: &str, label: &str) -> String { + let prefix = format!("- {label}: "); + let line = markdown.lines().find_map(|l| l.strip_prefix(prefix.as_str())); + literal(line.unwrap_or_else(|| panic!("no {label} in\n{markdown}"))) + } + + /// Two calls give the same bytes, with no document open, and the reference has one section + /// for each built-in command that has a schema, in registry order, ending with this command's. + #[test] + fn the_reference_lists_each_schema_once_in_registry_order_and_is_the_same_each_time() { + let mut s = Session::new(); + let first = s.execute(ID, &json!({})).unwrap(); + let second = s.execute(ID, &Value::Null).unwrap(); + assert_eq!(first, second); + let markdown = first["markdown"].as_str().unwrap(); + assert_eq!(markdown, reference(command_specs()).unwrap()); + assert!(markdown.len() < MAX_BYTES / 10, "{} bytes", markdown.len()); + let headings: Vec<String> = markdown.lines().filter_map(|l| l.strip_prefix("## ")).map(literal).collect(); + let expected: Vec<&str> = command_specs().iter().filter(|c| c.schema.is_some()).map(|c| c.id).collect(); + assert_eq!(headings, expected); + assert_eq!(headings.last().map(String::as_str), Some(ID)); + assert!(expected.contains(&"view.saved.new") && !headings.iter().any(|h| h == "file.new")); + let new_view = &markdown[markdown.find("## view.saved.new").unwrap()..]; + let names: Vec<String> = new_view + .lines() + .skip_while(|l| !l.starts_with("|---")) + .skip(1) + .take_while(|l| l.starts_with('|')) + .map(|l| literal(&cells(l)[0])) + .collect(); + assert_eq!(names, ["name", "center", "zoom", "rotation"]); + let edit = &markdown[markdown.find("## view.saved.edit").unwrap()..]; + assert_eq!(row(edit, "edits[].newName")[8], "null reads as omitted"); + assert_eq!(row(edit, "newName")[8], "rename; null reads as omitted"); + } + + const INNER: &[Param] = + &[Param::new("size", Type::Integer { min: Some(1), max: Some(10) }), Param::new("mode", Type::Enum(&["a", "b"])).defaults_to("\"a\"")]; + + static FULL: ParamSchema = ParamSchema::new( + &[ + Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(64.0)), unit: Some("pt") }) + .doc("1 = 100%"), + Param::new("name", Type::String).default_note("the first free name").null_omitted(), + Param::new("old", Type::Boolean).defaults_to("true").since(2).deprecated(3).lenient(), + Param::new("edits", Type::Array(&Type::Object(INNER))).optional(), + Param::new("ids", Type::Ids).optional(), + Param::new("at", Type::Point).optional(), + Param::new("color", Type::Color).optional(), + Param::new("id", Type::Id).optional(), + Param::new("ratio", Type::Number { min: Some(Bound::Inclusive(-1.5)), max: Some(Bound::Exclusive(2.0)), unit: None }).optional(), + ], + "do the thing", + ) + .returns("{done}") + .writes(Writes::Preferences) + .since(2) + .deprecated(4) + .cost(5); + + /// Each field of a schema shows, nested members in their order under dotted names. + #[test] + fn every_field_of_a_schema_is_shown() { + let markdown = reference(&[spec("test.full", &["View", "Saved"], Some(&FULL))]).unwrap(); + for (label, shown) in [ + ("Label", "Label"), + ("Menu", "View › Saved"), + ("API level", "2"), + ("Deprecated", "API level 4"), + ("Writes", "preferences"), + ("Cost", "5"), + ("Summary", "do the thing"), + ("Returns", "{done}"), + ] { + assert_eq!(item(&markdown, label), shown, "{label}"); + } + let lenient = "lenient (a value of another type or out of range is passed to the command, not refused)"; + let expected: [[&str; 9]; 11] = [ + ["zoom", "number", "more than 0, at most 64", "pt", "None", "Yes", "1", "None", "1 = 100%"], + ["name", "string", "None", "None", "the first free name (worked out by the command)", "No", "1", "None", "null reads as omitted"], + ["old", "boolean", "None", "None", "true (JSON)", "No", "2", "API level 3", lenient], + ["edits", "list of object", "None", "None", "None", "No", "1", "None", "None"], + ["edits[].size", "integer", "at least 1, at most 10", "None", "None", "Yes", "1", "None", "None"], + ["edits[].mode", "one of \"a\", \"b\"", "None", "None", "\"a\" (JSON)", "No", "1", "None", "None"], + ["ids", "list of object ids", "None", "None", "None", "No", "1", "None", "None"], + ["at", "point ([x, y], two numbers)", "None", "None", "None", "No", "1", "None", "None"], + ["color", "color (a string, [r, g, b] or an object)", "None", "None", "None", "No", "1", "None", "None"], + ["id", "object id (integer)", "at least 0, at most 9007199254740991", "None", "None", "No", "1", "None", "None"], + ["ratio", "number", "at least -1.5, less than 2", "None", "None", "No", "1", "None", "None"], + ]; + let names: Vec<String> = + markdown.lines().filter(|l| l.starts_with("| ") && !l.starts_with("| Parameter")).map(|l| literal(&cells(l)[0])).collect(); + assert_eq!(names, expected.map(|r| r[0])); + for want in expected { + assert_eq!(row(&markdown, want[0]), want, "{}", want[0]); + } + } + + const HOSTILE: &str = "a | b [link](https://evil.example) <script>x</script> `code` \\ *em* _u_ ![img](x) & ~~s~~ # h www.example.com me@example.com\r\nnext\tline"; + + static ESCAPES: ParamSchema = + ParamSchema::new(&[Param::new("a|b", Type::String).doc(HOSTILE).defaults_to("\"<b>|</b>\"")], HOSTILE).returns(HOSTILE); + + /// Markdown in a schema's text shows as plain text: a description stays in its own cell of a + /// nine-cell row, every punctuation character is escaped, and line breaks and tabs are spaces. + #[test] + fn markdown_in_schema_text_stays_literal_in_its_cell() { + let markdown = reference(&[spec("test.escapes", &["A|B", "[x](y)"], Some(&ESCAPES))]).unwrap(); + let shown = HOSTILE.replace(['\r', '\n', '\t'], " "); + let cells = row(&markdown, "a|b"); + assert_eq!(cells[4], "\"<b>|</b>\" (JSON)"); + assert_eq!(cells[8], shown); + assert_eq!(item(&markdown, "Summary"), shown); + assert_eq!(item(&markdown, "Returns"), shown); + assert_eq!(item(&markdown, "Menu"), "A|B › [x](y)"); + assert!(markdown.contains("www\\.example"), "{markdown}"); + let table: Vec<&str> = markdown.lines().filter(|l| l.starts_with('|')).collect(); + assert_eq!(table.len(), 3, "the header, the delimiter row and one row: {table:?}"); + assert!(!markdown.contains('\r') && !markdown.contains('\t')); + } + + /// A registry with no schema gives the header and a line that says so; a command without a + /// schema adds nothing. + #[test] + fn a_registry_without_schemas_gives_a_document_that_says_so() { + let empty = format!("{HEADER}\nNo commands have parameter schemas.\n"); + assert_eq!(reference(&[]).unwrap(), empty); + assert_eq!(reference(&[spec("test.none", &["View"], None)]).unwrap(), empty); + let one = reference(&[spec("test.none", &[], None), spec("test.full", &[], Some(&FULL)), spec("test.other", &[], None)]).unwrap(); + assert_eq!(one, reference(&[spec("test.full", &[], Some(&FULL))]).unwrap()); + } + + /// A reference of exactly [`MAX_BYTES`] is returned; one byte more is an error naming the + /// command, with no part of the reference. + #[test] + fn a_reference_past_the_limit_is_an_error() { + let with = |summary: String| -> Result<String> { + let schema: &'static ParamSchema = Box::leak(Box::new(ParamSchema::new(&[], Box::leak(summary.into_boxed_str())))); + reference(&[spec("test.big", &[], Some(schema))]) + }; + let base = with("x".into()).unwrap().len(); + let fits = with("x".repeat(MAX_BYTES - base + 1)).unwrap(); + assert_eq!(fits.len(), MAX_BYTES); + let e = with("x".repeat(MAX_BYTES - base + 2)).unwrap_err().to_string(); + assert!(e.contains(ID) && e.contains("larger than 1048576 bytes"), "{e}"); + assert!(e.len() < 200, "{e}"); + } + + /// An unknown key is refused with a parameter error naming it, and the command needs no + /// document. + #[test] + fn an_unknown_parameter_is_refused_naming_it() { + let mut s = Session::new(); + let e = s.execute(ID, &json!({"prefix": "view."})).unwrap_err().to_string(); + assert!(e.contains(ID) && e.contains("`prefix`"), "{e}"); + assert!(s.execute(ID, &json!([])).is_err()); + assert!(s.active().is_none()); + assert!(s.execute(ID, &json!({})).is_ok()); + } + + #[test] + fn escape_marks_each_punctuation_character_and_flattens_control_characters() { + assert_eq!(escape("a|b"), "a\\|b"); + assert_eq!(escape("WWW.x and w.x"), "WWW\\.x and w.x"); + assert_eq!(escape("é\u{7}\u{85}z"), "é z"); + assert_eq!(escape("1.5 > 1"), "1.5 \\> 1"); + } +} diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index a24613a07..61ec8f0fd 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -26,10 +26,10 @@ use crate::cmd::{CommandSpec, bad, command_specs}; use crate::{EngineError, Result}; /// The API level of the commands' parameters. -pub const API_VERSION: u32 = 1; +pub const API_VERSION: u32 = 2; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. -pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c)]; +pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653)]; /// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { @@ -296,8 +296,12 @@ mod tests { ("view.capsLock", json!([])), ("view.saved.new", json!(5)), ("view.transparencyGrid", json!("on")), + ("engine.apiReference", json!({})), + ("engine.apiReference", json!(null)), + ("engine.apiReference", json!({"prefix": "view."})), + ("engine.apiReference", json!([])), ]; - assert_eq!(schemas().len(), 6); + assert_eq!(schemas().len(), 7); for (id, schema) in schemas() { let written = schema.to_json(); check_schema(&written, id).unwrap_or_else(|e| panic!("{e}\n{written:#}")); diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index 50792de6d..9f2f3b99e 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -18,6 +18,20 @@ pub const DOC_JSON_URI: &str = "vectorcraft://document/json"; /// The live command catalog, including enabled state. pub const COMMANDS_URI: &str = "vectorcraft://commands"; +/// The command API reference in Markdown (`engine.apiReference`). +pub const REFERENCE_URI: &str = "vectorcraft://commands/reference"; + +/// The media type of the JSON resources. +pub const JSON: &str = "application/json"; +/// The media type of the command API reference. +pub const MARKDOWN: &str = "text/markdown; charset=utf-8"; + +/// What `resources/read` returns for a URI: the text and its media type. +#[derive(Debug, PartialEq)] +pub struct Contents { + pub mime: &'static str, + pub text: String, +} /// Why a resource could not be read. The distinction matters: the server turns it into `-32002` /// (no such resource) or `-32602` (bad URI), and guessing from the message text would not hold. @@ -83,9 +97,24 @@ pub fn list() -> Value { {"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"}, {"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"}, {"uri": COMMANDS_URI, "name": "commands", "title": "Command catalog", "description": "Command ids, labels, parameter descriptions and current enabled state", "mimeType": "application/json"}, + {"uri": REFERENCE_URI, "name": "command-reference", "title": "Command API reference", "description": "Markdown reference of the commands that have a parameter schema: types, ranges, units, defaults, API levels, what each writes and returns (engine.apiReference)", "mimeType": MARKDOWN}, ]}) } +/// `resources/read`: the text behind a resource URI, the command API reference as Markdown and +/// every other resource as indented JSON ([`read`]). +pub fn read_contents(b: &mut dyn Backend, uri: &str) -> Result<Contents, ReadError> { + if uri == REFERENCE_URI { + let mut reply = exec(b, "engine.apiReference").map_err(ReadError::Backend)?; + return match reply.get_mut("markdown").map(Value::take) { + Some(Value::String(text)) => Ok(Contents { mime: MARKDOWN, text }), + _ => Err(ReadError::Backend("engine.apiReference returned no `markdown` text".into())), + }; + } + let v = read(b, uri)?; + Ok(Contents { mime: JSON, text: serde_json::to_string_pretty(&v).unwrap_or_default() }) +} + /// `resources/templates/list`. pub fn templates() -> Value { let list: Vec<Value> = TEMPLATES @@ -246,7 +275,7 @@ mod tests { #[test] fn lists_are_well_formed() { let l = list(); - assert_eq!(l["resources"].as_array().map(Vec::len), Some(3)); + assert_eq!(l["resources"].as_array().map(Vec::len), Some(4)); let t = templates(); let items = t["resourceTemplates"].as_array().expect("templates"); assert_eq!(items.len(), TEMPLATES.len()); @@ -257,6 +286,51 @@ mod tests { } } + /// A backend that gives every call the same reply. + struct Fixed(Result<Value, String>); + + impl Backend for Fixed { + fn call(&mut self, _: &str, _: Value) -> Result<Value, String> { + self.0.clone() + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "fixed".into() + } + } + + /// The command reference is listed and read as Markdown, with the text `engine.apiReference` + /// returns, and with no document open; the other fixed resources still read as JSON. + #[test] + fn the_command_reference_reads_as_markdown() { + let listed = list(); + let entry = listed["resources"].as_array().and_then(|r| r.iter().find(|r| r["uri"] == REFERENCE_URI)).expect("listed"); + assert_eq!(entry["mimeType"], MARKDOWN); + assert_eq!(entry["name"], "command-reference"); + let mut b = Box::new(crate::Headless::new()); + let read = read_contents(b.as_mut(), REFERENCE_URI).unwrap(); + assert_eq!(read.mime, MARKDOWN); + let direct = b.call("engine.execute", json!({"command": "engine.apiReference", "params": {}})).unwrap(); + assert_eq!(read.text, direct["markdown"].as_str().unwrap()); + assert!(read.text.starts_with("# VectorCraft command API reference\n"), "{}", read.text); + assert_eq!(read_contents(b.as_mut(), REFERENCE_URI).unwrap(), read); + let commands = read_contents(b.as_mut(), COMMANDS_URI).unwrap(); + assert_eq!(commands.mime, JSON); + assert!(serde_json::from_str::<Value>(&commands.text).unwrap().is_array()); + assert!(matches!(read_contents(b.as_mut(), "vectorcraft://commands/nope"), Err(ReadError::NotFound(_)))); + } + + /// A reply without the Markdown text, and an app that cannot be reached, are backend errors. + #[test] + fn a_bad_or_failed_reference_reply_is_a_backend_error() { + for reply in [Ok(json!({})), Ok(json!({"markdown": 5})), Ok(json!("text")), Err("the app is not running".to_string())] { + let mut b = Fixed(reply.clone()); + assert!(matches!(read_contents(&mut b, REFERENCE_URI), Err(ReadError::Backend(_))), "{reply:?}"); + } + } + #[test] fn missing_and_unknown_uris_are_errors() { let mut b = Box::new(crate::Headless::with_document()); diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index c0139bccc..b1cfd3087 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -439,12 +439,12 @@ impl Server { } "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; - let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e { + let read = resources::read_contents(self.backend.as_mut(), uri).map_err(|e| match e { resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")), resources::ReadError::Invalid(why) => (INVALID_PARAMS, why), resources::ReadError::Backend(why) => (INTERNAL_ERROR, why), })?; - Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) + Ok(json!({"contents": [{"uri": uri, "mimeType": read.mime, "text": read.text}]})) } "completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)), "logging/setLevel" => { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index e986deda0..c07aa9efc 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -304,7 +304,7 @@ fn headless_end_to_end() { // Resources. let v = rpc(&mut s, 10, "resources/list", json!({})); - assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 3); + assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 4); let v = rpc(&mut s, 11, "resources/read", json!({"uri": "vectorcraft://document"})); let text = v["result"]["contents"][0]["text"].as_str().unwrap(); assert!(serde_json::from_str::<Value>(text).unwrap()["layers"].is_array()); diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index 2eb3db647..f9dc065ff 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -290,8 +290,16 @@ fn resources_are_json() { let list = rpc(&mut s, 2, "resources/list", json!({})); for r in list["result"]["resources"].as_array().unwrap() { let v = rpc(&mut s, 3, "resources/read", json!({"uri": r["uri"]})); - let text = v["result"]["contents"][0]["text"].as_str().unwrap(); - serde_json::from_str::<Value>(text).unwrap(); + let content = &v["result"]["contents"][0]; + assert_eq!(content["mimeType"], r["mimeType"], "{}", r["uri"]); + assert_eq!(content["uri"], r["uri"]); + let text = content["text"].as_str().unwrap(); + if r["mimeType"] == "application/json" { + serde_json::from_str::<Value>(text).unwrap(); + } else { + assert_eq!(r["mimeType"], "text/markdown; charset=utf-8", "{}", r["uri"]); + assert!(text.starts_with("# ") && text.ends_with('\n'), "{}", r["uri"]); + } } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d58bda3aa..1f3d5dc90 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -567,6 +567,7 @@ font-technology Variable Променливи Coming soon — tracked in the parity plan Очаквайте скоро — следи се в плана за съвместимост Command Click to Select Objects Behind Щракване с Command за избор на обекти отзад Command Dialog Диалогов прозорец за команда + Command API Reference Справочник за API на командите Community Общност Compatibility: Съвместимост: Compatible Gradient and Gradient Mesh Printing Съвместим печат на градиент и градиентна мрежа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4d57ec80b..b432ce9dd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -965,6 +965,7 @@ lighting Light Svjetlo Color Picker… Birač boja… Coming soon — tracked in the parity plan Uskoro — prati se u planu usklađenosti Command Dialog Dijalog naredbe + Command API Reference Referenca API-ja naredbi Copy Selected Style Kopiraj odabrani stil Corners… Uglovi… Define Perspective Grid… Definiši mrežu perspektive… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5ed856ac9..b00834c4e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -794,6 +794,7 @@ antiAlias Sharp Nítid Command Ordre Command Click to Select Objects Behind Clic amb Ordre per seleccionar els objectes del darrere Command Dialog Diàleg d’ordres + Command API Reference Referència de l’API d’ordres Comment not closed Comentari no tancat Commenting, filling in form fields and signing Comentar, emplenar camps de formulari i signar Commits Commits @@ -5432,6 +5433,7 @@ digits Malayalam Malaiàlam @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos @msg not an action set file no és un fitxer de conjunt d’accions @msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referència superaria els {MAX_BYTES} bytes @msg the action set file holds more than 1,000 actions el fitxer de conjunt d’accions conté més de 1.000 accions @msg the action set file needs a later version of VectorCraft el fitxer de conjunt d’accions necessita una versió més recent del VectorCraft @msg an action in the action set file has more than 10,000 steps una acció del fitxer de conjunt d’accions té més de 10.000 passos diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5792e9ca1..bfa4fc62e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -985,6 +985,7 @@ lighting Light Světlo Color Picker… Výběr barvy… Coming soon — tracked in the parity plan Připravujeme — sledováno v plánu parity Command Dialog Dialogové okno příkazu + Command API Reference Reference API příkazů Copy Selected Style Kopírovat vybraný styl Corners… Rohy… Define Perspective Grid… Definovat perspektivní mřížku… @@ -4722,6 +4723,7 @@ imageTrace Outline Obrys @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků @msg not an action set file není to soubor sady akcí @msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: reference by byla větší než {MAX_BYTES} bajtů @msg the action set file holds more than 1,000 actions soubor sady akcí obsahuje více než 1 000 akcí @msg the action set file needs a later version of VectorCraft soubor sady akcí vyžaduje novější verzi aplikace VectorCraft @msg an action in the action set file has more than 10,000 steps akce v souboru sady akcí má více než 10 000 kroků diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bf2efab42..ceb4dc632 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -906,6 +906,7 @@ lighting Light Lys Color Picker… Farvevælger… Coming soon — tracked in the parity plan Kommer snart — følges i paritetsplanen Command Dialog Kommandodialog + Command API Reference API-reference for kommandoer Copy Selected Style Kopiér markeret typografi Corners… Hjørner… Define Perspective Grid… Definér perspektivgitter… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 37a1b3b44..a8a2e3e05 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -560,6 +560,7 @@ font-technology Variable Variabel Coming soon — tracked in the parity plan Demnächst verfügbar – im Paritätsplan vermerkt Command Click to Select Objects Behind Cmd-Klick wählt dahinterliegende Objekte aus Command Dialog Befehlsdialog + Command API Reference API-Referenz der Befehle Community Community Compatibility: Kompatibilität: Compatible Gradient and Gradient Mesh Printing Kompatibles Drucken von Verläufen und Verlaufsgittern @@ -4329,6 +4330,7 @@ theme Appearance Erscheinungsbild @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen @msg not an action set file keine Aktionssatzdatei @msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: die Referenz wäre größer als {MAX_BYTES} Byte @msg the action set file holds more than 1,000 actions die Aktionssatzdatei enthält mehr als 1.000 Aktionen @msg the action set file needs a later version of VectorCraft die Aktionssatzdatei erfordert eine neuere Version von VectorCraft @msg an action in the action set file has more than 10,000 steps eine Aktion in der Aktionssatzdatei hat mehr als 10.000 Schritte diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 25baaaa17..30e5675bb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -542,6 +542,7 @@ font-technology Variable Μεταβλητές Coming soon — tracked in the parity plan Προσεχώς — περιλαμβάνεται στο σχέδιο αντιστοιχίας λειτουργιών Command Click to Select Objects Behind Command-κλικ για επιλογή αντικειμένων από πίσω Command Dialog Παράθυρο εντολών + Command API Reference Αναφορά API εντολών Community Κοινότητα Compatibility: Συμβατότητα: Compatible Gradient and Gradient Mesh Printing Συμβατή εκτύπωση ντεγκραντέ και πλέγματος ντεγκραντέ @@ -4367,6 +4368,7 @@ digits Malayalam Μαλαγιάλαμ @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα @msg not an action set file δεν είναι αρχείο συνόλου ενεργειών @msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: η αναφορά θα ήταν μεγαλύτερη από {MAX_BYTES} byte @msg the action set file holds more than 1,000 actions το αρχείο συνόλου ενεργειών περιέχει περισσότερες από 1.000 ενέργειες @msg the action set file needs a later version of VectorCraft το αρχείο συνόλου ενεργειών απαιτεί νεότερη έκδοση του VectorCraft @msg an action in the action set file has more than 10,000 steps μια ενέργεια στο αρχείο συνόλου ενεργειών έχει περισσότερα από 10.000 βήματα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dca929ff2..4c12636f9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -582,6 +582,7 @@ font-technology Variable Variables Coming soon — tracked in the parity plan Próximamente: incluido en el plan de paridad Command Click to Select Objects Behind Cmd+clic para seleccionar objetos de detrás Command Dialog Cuadro de diálogo de comando + Command API Reference Referencia de la API de comandos Community Comunidad Compatibility: Compatibilidad: Compatible Gradient and Gradient Mesh Printing Impresión compatible de degradados y mallas de degradado @@ -4413,6 +4414,7 @@ axis Both Ambos @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos @msg not an action set file no es un archivo de conjunto de acciones @msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referencia superaría los {MAX_BYTES} bytes @msg the action set file holds more than 1,000 actions el archivo de conjunto de acciones contiene más de 1000 acciones @msg the action set file needs a later version of VectorCraft el archivo de conjunto de acciones necesita una versión más reciente de VectorCraft @msg an action in the action set file has more than 10,000 steps una acción del archivo de conjunto de acciones tiene más de 10 000 pasos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1a1058dd9..e9bb13d60 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -964,6 +964,7 @@ lighting Light Valo Color Picker… Värivalitsin… Coming soon — tracked in the parity plan Tulossa pian — seurataan yhdenmukaisuussuunnitelmassa Command Dialog Komentovalintaikkuna + Command API Reference Komentojen API-viite Copy Selected Style Kopioi valittu tyyli Corners… Kulmat… Define Perspective Grid… Määritä perspektiiviruudukko… @@ -4630,6 +4631,7 @@ imageTrace Outline Ääriviiva @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta @msg not an action set file ei ole toimintosarjatiedosto @msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: viite olisi suurempi kuin {MAX_BYTES} tavua @msg the action set file holds more than 1,000 actions toimintosarjatiedostossa on yli 1 000 toimintoa @msg the action set file needs a later version of VectorCraft toimintosarjatiedosto vaatii uudemman VectorCraft-version @msg an action in the action set file has more than 10,000 steps toimintosarjatiedoston toiminnossa on yli 10 000 vaihetta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3b28b43f2..ddbcf4d19 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -583,6 +583,7 @@ font-technology Variable Variables Coming soon — tracked in the parity plan Bientôt disponible — prévu dans le plan de parité Command Click to Select Objects Behind Cmd+clic pour sélectionner les objets situés derrière Command Dialog Boîte de dialogue des commandes + Command API Reference Référence de l’API des commandes Community Communauté Compatibility: Compatibilité : Compatible Gradient and Gradient Mesh Printing Impression compatible des dégradés et des filets de dégradé @@ -4402,6 +4403,7 @@ axis Both Les deux @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées @msg not an action set file ce n’est pas un fichier de jeu de scripts @msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}` : la référence dépasserait {MAX_BYTES} octets @msg the action set file holds more than 1,000 actions le fichier de jeu de scripts contient plus de 1 000 scripts @msg the action set file needs a later version of VectorCraft le fichier de jeu de scripts nécessite une version plus récente de VectorCraft @msg an action in the action set file has more than 10,000 steps un script du fichier de jeu de scripts compte plus de 10 000 étapes diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 4e9cd6f41..5971e539f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -896,6 +896,7 @@ antiAlias Sharp शार्प Command कमांड Command Click to Select Objects Behind कमांड पीछे ऑब्जेक्ट्स का सिलेक्ट करने के लिए क्लिक करें Command Dialog कमान डायलॉग + Command API Reference कमांड API संदर्भ Comment not closed टिप्पणी बंद नहीं Commenting, filling in form fields and signing टिप्पणी करना, फॉर्म फ़ील्ड में भरना और हस्ताक्षर करना Commits कमिट्स @@ -7385,6 +7386,7 @@ antiAlias Sharp शार्प @msg the action has no steps कार्रवाई में कोई कदम नहीं है @msg the action set file holds more than 1,000 actions एक्शन सेट फ़ाइल 1,000 से अधिक कार्यों को रखती है @msg the action set file is larger than 8 MiB एक्शन सेट फ़ाइल 8 MiB से बड़ा है +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: संदर्भ {MAX_BYTES} बाइट से बड़ा होगा @msg the action set file needs a later version of VectorCraft एक्शन सेट फ़ाइल को वेक्टर क्रेफ्ट के बाद के संस्करण की आवश्यकता होती है @msg the action set ids are not a list कार्रवाई सेट ids एक सूची नहीं हैं @msg the action sets changed; nothing was deleted कार्रवाई सेट बदल गया; कुछ भी नहीं हटा दिया गया था diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8ed5bc017..578339347 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -760,6 +760,7 @@ font-technology Variable Változó Command Parancs Command Click to Select Objects Behind Cmd+kattintás a mögöttes objektumok kijelöléséhez Command Dialog Parancs párbeszédablaka + Command API Reference Parancsok API-referenciája Commenting, filling in form fields and signing Megjegyzések, űrlapmezők kitöltése és aláírás Commits Commitok Community Közösség @@ -5095,6 +5096,7 @@ imageTrace Outline Körvonal @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n} @msg not an action set file nem műveletkészlet-fájl @msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referencia nagyobb lenne {MAX_BYTES} bájtnál @msg the action set file holds more than 1,000 actions a műveletkészlet-fájl több mint 1000 műveletet tartalmaz @msg the action set file needs a later version of VectorCraft a műveletkészlet-fájlhoz a VectorCraft újabb verziója kell @msg an action in the action set file has more than 10,000 steps a műveletkészlet-fájl egyik műveletében több mint 10 000 lépés van diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 459e32a60..05d08d4bd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -906,6 +906,7 @@ lighting Light Cahaya Color Picker… Pemilih warna… Coming soon — tracked in the parity plan Segera hadir — dilacak dalam rencana paritas Command Dialog Dialog perintah + Command API Reference Referensi API perintah Copy Selected Style Salin gaya terpilih Corners… Sudut… Define Perspective Grid… Definisikan kisi perspektif… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d08128427..f225c4d5a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -582,6 +582,7 @@ font-technology Variable Variabili Coming soon — tracked in the parity plan Prossimamente — previsto nel piano di parità Command Click to Select Objects Behind Cmd+clic per selezionare gli oggetti retrostanti Command Dialog Finestra dei comandi + Command API Reference Riferimento API dei comandi Community Comunità Compatibility: Compatibilità: Compatible Gradient and Gradient Mesh Printing Stampa compatibile di sfumature e trame sfumatura @@ -4455,6 +4456,7 @@ axis Both Entrambi @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi @msg not an action set file non è un file di set di azioni @msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: il riferimento supererebbe {MAX_BYTES} byte @msg the action set file holds more than 1,000 actions il file del set di azioni contiene più di 1.000 azioni @msg the action set file needs a later version of VectorCraft il file del set di azioni richiede una versione più recente di VectorCraft @msg an action in the action set file has more than 10,000 steps un’azione nel file del set di azioni ha più di 10.000 passaggi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 11d1b5ced..3d176d839 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1253,6 +1253,7 @@ antiAlias Sharp シャープ Coming soon — tracked in the parity plan 近日対応予定 Command Click to Select Objects Behind Command+クリックで背面のオブジェクトを選択 Command Dialog コマンドダイアログ + Command API Reference コマンド API リファレンス Community コミュニティ Compatibility: 互換性: Compatible Gradient and Gradient Mesh Printing 互換性のあるグラデーションとグラデーションメッシュのプリント @@ -4801,6 +4802,7 @@ swatch Bright Violet ビビッドバイオレット @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました @msg not an action set file アクションセットファイルではありません @msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: リファレンスが {MAX_BYTES} バイトを超えます @msg the action set file holds more than 1,000 actions アクションセットファイルのアクションが 1,000 個を超えています @msg the action set file needs a later version of VectorCraft このアクションセットファイルには新しいバージョンの VectorCraft が必要です @msg an action in the action set file has more than 10,000 steps アクションセットファイルに 10,000 ステップを超えるアクションがあります diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 63a17e969..2595036c2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -858,6 +858,7 @@ lighting Light სინათლე Color Picker… ფერის ამრჩევი… Coming soon — tracked in the parity plan მალე — აღრიცხულია თანხვედრის გეგმაში Command Dialog ბრძანების დიალოგი + Command API Reference ბრძანებების API ცნობარი Copy Selected Style მონიშნული სტილის კოპირება Corners… კუთხეები… Define Perspective Grid… პერსპექტივის ბადის განსაზღვრა… @@ -4180,6 +4181,7 @@ font-technology Variable ცვლადი @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n} @msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი @msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: ცნობარი {MAX_BYTES} ბაიტზე დიდი იქნებოდა @msg the action set file holds more than 1,000 actions მოქმედებების ნაკრების ფაილი 1000-ზე მეტ მოქმედებას შეიცავს @msg the action set file needs a later version of VectorCraft მოქმედებების ნაკრების ფაილს VectorCraft-ის უფრო ახალი ვერსია სჭირდება @msg an action in the action set file has more than 10,000 steps მოქმედებების ნაკრების ფაილში ერთ-ერთ მოქმედებას 10 000-ზე მეტი ნაბიჯი აქვს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1463307af..0cc31464f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1243,6 +1243,7 @@ antiAlias Sharp 날카롭게 Coming soon — tracked in the parity plan 곧 제공 예정 — 기능 동등성 계획에서 관리 중 Command Click to Select Objects Behind Command-클릭으로 뒤쪽 개체 선택 Command Dialog 명령 대화상자 + Command API Reference 명령 API 참조 Community 커뮤니티 Compatibility: 호환성: Compatible Gradient and Gradient Mesh Printing 호환 그레이디언트 및 그레이디언트 메시 인쇄 @@ -4728,6 +4729,7 @@ theme Appearance 외관 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다 @msg not an action set file 액션 세트 파일이 아닙니다 @msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다 +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: 참조가 {MAX_BYTES}바이트보다 커집니다 @msg the action set file holds more than 1,000 actions 액션 세트 파일의 액션이 1,000개를 넘습니다 @msg the action set file needs a later version of VectorCraft 이 액션 세트 파일을 열려면 최신 버전의 VectorCraft가 필요합니다 @msg an action in the action set file has more than 10,000 steps 액션 세트 파일에 단계가 10,000개를 넘는 액션이 있습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e512752ea..d38e11ef7 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -920,6 +920,7 @@ lighting Light Licht Color Picker… Kleurkiezer… Coming soon — tracked in the parity plan Binnenkort beschikbaar — opgenomen in het pariteitsplan Command Dialog Opdrachtdialoogvenster + Command API Reference API-naslag voor opdrachten Copy Selected Style Geselecteerde stijl kopiëren Corners… Hoeken… Define Perspective Grid… Perspectiefraster definiëren… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5469364e6..f8c4a23f7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -921,6 +921,7 @@ lighting Light Światło Color Picker… Próbnik kolorów… Coming soon — tracked in the parity plan Już wkrótce — ujęte w planie zgodności Command Dialog Okno polecenia + Command API Reference Dokumentacja API poleceń Copy Selected Style Kopiuj zaznaczony styl Corners… Narożniki… Define Perspective Grid… Zdefiniuj siatkę perspektywy… @@ -4545,6 +4546,7 @@ imageTrace Outline Kontur @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków @msg not an action set file to nie jest plik zestawu operacji @msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: dokumentacja byłaby większa niż {MAX_BYTES} bajtów @msg the action set file holds more than 1,000 actions plik zestawu operacji zawiera ponad 1000 operacji @msg the action set file needs a later version of VectorCraft plik zestawu operacji wymaga nowszej wersji programu VectorCraft @msg an action in the action set file has more than 10,000 steps operacja w pliku zestawu operacji ma ponad 10 000 kroków diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b126286a0..30a61dae3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -532,6 +532,7 @@ antiAlias Sharp Nítido Coming soon — tracked in the parity plan Em breve — rastreado no plano de paridade Command Click to Select Objects Behind Clique com Comando para Selecionar Objetos Atrás Command Dialog Diálogo de Comandos + Command API Reference Referência da API de Comandos Community Comunidade Compatibility: Compatibilidade: Compatible Gradient and Gradient Mesh Printing Impressão Compatível de Gradiente e Malha de Gradiente @@ -3985,6 +3986,7 @@ axis Both Ambos @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas @msg not an action set file não é um arquivo de conjunto de ações @msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referência teria mais de {MAX_BYTES} bytes @msg the action set file holds more than 1,000 actions o arquivo de conjunto de ações contém mais de 1.000 ações @msg the action set file needs a later version of VectorCraft o arquivo de conjunto de ações requer uma versão mais recente do VectorCraft @msg an action in the action set file has more than 10,000 steps uma ação do arquivo de conjunto de ações tem mais de 10.000 etapas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d54e7d199..dd954cea0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -580,6 +580,7 @@ font-technology Variable Вариативные Coming soon — tracked in the parity plan Скоро — отслеживается в плане развития Command Click to Select Objects Behind Cmd+щелчок для выбора объектов позади Command Dialog Диалоговое окно команды + Command API Reference Справочник по API команд Community Сообщество Compatibility: Совместимость: Compatible Gradient and Gradient Mesh Printing Совместимая печать градиентов и градиентных сеток @@ -4449,6 +4450,7 @@ axis Both Оба @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов @msg not an action set file это не файл набора действий @msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: справочник был бы больше {MAX_BYTES} байт @msg the action set file holds more than 1,000 actions файл набора действий содержит более 1000 действий @msg the action set file needs a later version of VectorCraft для файла набора действий нужна более новая версия VectorCraft @msg an action in the action set file has more than 10,000 steps в файле набора действий есть действие более чем из 10 000 шагов diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2fe873c72..d83100b8c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -569,6 +569,7 @@ font-technology Variable Variabla Coming soon — tracked in the parity plan Kommer snart — följs upp i paritetsplanen Command Click to Select Objects Behind Cmd-klicka för att markera objekt bakom Command Dialog Kommandodialog + Command API Reference API-referens för kommandon Community Gemenskap Compatibility: Kompatibilitet: Compatible Gradient and Gradient Mesh Printing Kompatibel utskrift av gradienter och gradientnät @@ -4317,6 +4318,7 @@ axis Both Båda @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades @msg not an action set file inte en fil med åtgärdsuppsättning @msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: referensen skulle bli större än {MAX_BYTES} byte @msg the action set file holds more than 1,000 actions filen med åtgärdsuppsättningen innehåller fler än 1 000 åtgärder @msg the action set file needs a later version of VectorCraft filen med åtgärdsuppsättningen kräver en nyare version av VectorCraft @msg an action in the action set file has more than 10,000 steps en åtgärd i filen med åtgärdsuppsättningen har fler än 10 000 steg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e3f73973c..ba54e4058 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -563,6 +563,7 @@ font-technology Variable Değişken Coming soon — tracked in the parity plan Çok yakında — eşlik planında takip ediliyor Command Click to Select Objects Behind Komut Arkasındaki Nesneleri Seçmek İçin Tıklayın Command Dialog Komut İletişim Kutusu + Command API Reference Komut API Başvurusu Community Topluluk Compatibility: Uyumluluk: Compatible Gradient and Gradient Mesh Printing Uyumlu Degrade ve Degrade Mesh Baskı @@ -4177,6 +4178,7 @@ axis Both Her İkisi @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı @msg not an action set file bir eylem seti dosyası değil @msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: başvuru {MAX_BYTES} bayttan büyük olur @msg the action set file holds more than 1,000 actions eylem seti dosyası 1.000'den fazla eylem içeriyor @msg the action set file needs a later version of VectorCraft eylem seti dosyası VectorCraft'ın daha yeni bir sürümünü gerektiriyor @msg an action in the action set file has more than 10,000 steps eylem seti dosyasındaki bir eylemde 10.000'den fazla adım var diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a91939074..056bf92ec 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -576,6 +576,7 @@ font-technology Variable Варіативні Coming soon — tracked in the parity plan Незабаром відстежується в плані розвитку Command Click to Select Objects Behind Cmd+клацання для вибору об’єктів позаду Command Dialog Діалогове вікно команди + Command API Reference Довідник API команд Community Спільнота Compatibility: Сумісність: Compatible Gradient and Gradient Mesh Printing Сумісний друк градієнтів та градієнтних сіток @@ -4439,6 +4440,7 @@ axis Both Обидві @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків @msg not an action set file це не файл набору дій @msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: довідник був би більшим за {MAX_BYTES} байтів @msg the action set file holds more than 1,000 actions файл набору дій містить понад 1000 дій @msg the action set file needs a later version of VectorCraft для файлу набору дій потрібна новіша версія VectorCraft @msg an action in the action set file has more than 10,000 steps у файлі набору дій є дія з понад 10 000 кроків diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f0780005..624a2dbff 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -671,6 +671,7 @@ font-technology Variable Biến thiên Coming soon — tracked in the parity plan Sắp ra mắt — đang theo dõi trong kế hoạch đối chiếu tính năng Command Click to Select Objects Behind Command-bấm để chọn đối tượng phía sau Command Dialog Hộp thoại lệnh + Command API Reference Tài liệu tham khảo API lệnh Commits Commit Community Cộng đồng Company Công ty @@ -4748,6 +4749,7 @@ theme Appearance Giao diện @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước @msg not an action set file không phải tệp bộ hành động @msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: tài liệu tham khảo sẽ lớn hơn {MAX_BYTES} byte @msg the action set file holds more than 1,000 actions tệp bộ hành động chứa hơn 1.000 hành động @msg the action set file needs a later version of VectorCraft tệp bộ hành động cần phiên bản VectorCraft mới hơn @msg an action in the action set file has more than 10,000 steps một hành động trong tệp bộ hành động có hơn 10.000 bước diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 65a59cabe..501819665 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -528,6 +528,7 @@ antiAlias Sharp 锐利 Coming soon — tracked in the parity plan 即将推出 — 在对标计划中跟踪 Command Click to Select Objects Behind 按住 Command 单击选择后方对象 Command Dialog 命令对话框 + Command API Reference 命令 API 参考 Community 社区 Compatibility: 兼容性: Compatible Gradient and Gradient Mesh Printing 兼容渐变和渐变网格打印 @@ -4508,6 +4509,7 @@ imageTrace Outline 轮廓 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤 @msg not an action set file 不是动作组文件 @msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:参考将大于 {MAX_BYTES} 字节 @msg the action set file holds more than 1,000 actions 动作组文件包含超过 1,000 个动作 @msg the action set file needs a later version of VectorCraft 动作组文件需要更高版本的 VectorCraft @msg an action in the action set file has more than 10,000 steps 动作组文件中有动作超过 10,000 个步骤 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b16ad5eec..eb983db79 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -544,6 +544,7 @@ font-technology Variable 可變 Coming soon — tracked in the parity plan 即將推出 — 已列入功能對等計畫 Command Click to Select Objects Behind Cmd 加按一下可選取後方物件 Command Dialog 指令對話框 + Command API Reference 指令 API 參考 Community 社群 Compatibility: 相容性: Compatible Gradient and Gradient Mesh Printing 相容的漸層與漸層網格列印 @@ -2352,6 +2353,7 @@ file-filter Templates 範本 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟 @msg not an action set file 不是動作組檔案 @msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:參考將大於 {MAX_BYTES} 位元組 @msg the action set file holds more than 1,000 actions 動作組檔案包含超過 1,000 個動作 @msg the action set file needs a later version of VectorCraft 動作組檔案需要較新版本的 VectorCraft @msg an action in the action set file has more than 10,000 steps 動作組檔案中有動作超過 10,000 個步驟 diff --git a/docs/mcp.md b/docs/mcp.md index e99a95c07..2cb91b78e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -210,8 +210,10 @@ echo '{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"poster"," ### Resource templates -`vectorcraft://document` (summary), `vectorcraft://document/json` (the whole model), and -`vectorcraft://commands` (the live command catalog with enabled state) are the fixed resources. The templates read one thing at a time, which matters on a real document: the full model +`vectorcraft://document` (summary), `vectorcraft://document/json` (the whole model), +`vectorcraft://commands` (the live command catalog with enabled state) and `vectorcraft://commands/reference` (the +command API reference in Markdown, `text/markdown; charset=utf-8`; see [Parameter schemas](#parameter-schemas)) are +the fixed resources. The templates read one thing at a time, which matters on a real document: the full model is thousands of lines an agent pays for again on every change. | `uriTemplate` | Reads | @@ -748,6 +750,7 @@ Fixed resources and the four templates (same as [Resource templates](#resource-t | `vectorcraft://document` | `document.inspect` summary (JSON) | | `vectorcraft://document/json` | The complete document model (JSON) | | `vectorcraft://commands` | Live command catalog with enabled state | +| `vectorcraft://commands/reference` | The command API reference (`engine.apiReference`), as Markdown (`text/markdown; charset=utf-8`) | | `vectorcraft://object/{id}` | One layer or object (`document.node {id, summary: true}`) | | `vectorcraft://command/{id}` | One command's metadata and enablement | | `vectorcraft://effect/{id}` | One live effect with parameters and defaults | @@ -1385,7 +1388,7 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd ### Parameter schemas Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`, -`view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options`. The engine's +`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options` and `engine.apiReference`. The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 (https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the command has a summary, and `deprecated: true` when the command is deprecated. The schema uses `type`, `properties`, `required` and @@ -1427,9 +1430,25 @@ path to a value inside a list or an object, and the document and its undo histor An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests -more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 1 -(`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Commands that plug-ins add have -no schema here. +more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 2 +(`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Level 2 added +`engine.apiReference`. Commands that plug-ins add have no schema here. + +`engine.apiReference {}` → `{markdown}` returns the command API reference, written from these schemas each time it +runs; no file of it is kept. It needs no open document and changes nothing. The reference has a `##` section for each +built-in command that has a schema, in registry order (the order `engine.commands` lists them), and leaves out every +command without one, plug-in commands included; `engine.commands` still lists those with their params text. A section +lists the command's label, menu path, API level, deprecation level, what it writes (`x-writes`), cost, summary and +return, then a table of its parameters in the schema's order with the columns Parameter, Type, Range, Unit, Default, +Required, Since, Deprecated and Description. A member of an object parameter is a row of its own after it, named with +dots and `[]` for a list (`edits[].name`). A range says whether each end is included ("more than 0", "at most 64"); a +default is either the JSON value the engine fills in, marked "(JSON)", or the note of a default the command works out; +the Description says when null reads as omitted and when the engine does not refuse a value of another type (a lenient +parameter). Text from a schema is escaped as Markdown text: each of `` \ | [ ] < > ` * _ ! & # ~ : @ `` and the `.` +after `www` get a backslash, and line breaks, tabs and other control characters become spaces, so a description cannot +end a table cell or make a link, an image or HTML. A reference larger than 1 MiB (1,048,576 bytes) is an error naming +`engine.apiReference`, and no part of it is returned. MCP serves the same text as the resource +`vectorcraft://commands/reference`. ## Checking scripts written for the reference app diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1321b98ec..e800e5388 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -672,6 +672,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. - **Input schemas in the command listings:** `engine.commands` and the MCP tools `command_list` and `list_commands` give each command with a parameter schema an `inputSchema` next to `schema`: the same schema with the root `type` `"object"`, the form an MCP tool's input schema takes ([MCP schema reference](https://modelcontextprotocol.io/specification/2026-07-28/schema)). A command without a schema lists neither, and `brief` leaves out both schemas with the `params` text. Tests: `schemas::tests::engine_commands_list_an_input_schema_with_an_object_root` and `schemas::tests::an_input_schema_keeps_deprecation` in `vectorcraft-engine`, and `tests::conventions::command_listings_carry_input_schemas` in `vectorcraft-mcp`, which check every listed input schema against the JSON Schema 2020-12 rules. +- **Command API reference:** `engine.apiReference` returns a Markdown reference written from the parameter schemas when it is asked for, and MCP serves the same text as the resource `vectorcraft://commands/reference` (`text/markdown; charset=utf-8`). Each command that has a schema gets a section in registry order with its label, menu path, API levels, what it writes, summary and return, and a table of its parameters (type, range with its ends marked included or not, unit, default, required, levels and description; members of objects as dotted rows). Commands without a schema, plug-in commands included, are left out. Schema text is escaped so it stays plain text in its table cell, the same build always returns the same bytes, and a reference past 1 MiB is an error rather than a cut-short text. The command's own schema raised the API level to 2. This is a VectorCraft documentation feature; no score changes. Tests: `cmd::apiref::tests` and `schemas::tests` in `vectorcraft-engine`, `resources::tests` in `vectorcraft-mcp`. - **Automation:** - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value. From c703cc56503e816a5990df453403770fc130e75f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 21:37:10 -0800 Subject: [PATCH 33/48] Revolve: a face drawn on its own keeps its fill or stroke's opacity and blending mode A face of a group member that paints nothing (its fill or stroke at 0% opacity) was drawn without a group, so its zero opacity was lost and the member drew fully opaque. Such a face now keeps a group with the opacity and blending mode of its fill or stroke. --- crates/effects/src/revolve.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index d737a0af9..89f3bba86 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -657,7 +657,8 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { } } let runs = runs.into_iter().filter_map(|mut r| { - if r.member.is_none() { + // A face on its own needs a group only for the opacity or blending mode of its fill or stroke. + if r.member.is_none() && r.opacity >= 1.0 && r.blend == Default::default() { return r.faces.pop().map(|face| (r.scopes, face)); } let mut group = Node::group(n.id, r.faces); @@ -949,6 +950,36 @@ mod tests { assert_eq!(groups(&art), 2, "one group for each faded ring"); } + #[test] + fn a_member_whose_fill_or_stroke_paints_nothing_stays_invisible() { + let clear_fill = { + let mut n = (*member(3, 20.0)).clone(); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.opacity = 0.0; + } + n + }; + let clear_stroke = { + let mut n = (*member(4, 20.0)).clone(); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0).into(); + for item in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = item { + s.opacity = 0.0; + } + } + n + }; + for clear in [clear_fill, clear_stroke] { + let art = revolve_art(&group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0), Arc::new(clear)])).unwrap(); + let faces = faces_in_order(&art); + let alone = faces_in_order(&revolve_art(&group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)])).unwrap()); + let opaque: Vec<_> = faces.iter().filter(|(_, s)| s.is_empty()).map(|(f, _)| f).collect(); + assert_eq!(opaque, alone.iter().map(|(f, _)| f).collect::<Vec<_>>(), "only the opaque members' faces draw without transparency"); + assert!(faces.len() > alone.len(), "the clear member's faces stay in the art"); + assert!(faces.iter().all(|(_, s)| s.is_empty() || s.iter().any(|(o, _)| *o == 0.0)), "each clear face is drawn at 0% opacity"); + } + } + fn star(id: u64) -> Arc<Node> { let anchors = (0..1200) .map(|i| { From 0d7c6dd6e21359389c780e06c5662954a016fcc3 Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Tue, 10 Nov 2026 10:48:52 +0500 Subject: [PATCH 34/48] Spelling right-click: Intertwine members by their tier at the point (part of #4936) --- crates/ui-egui/src/canvas/tool_overlays.rs | 25 +++++++++----- crates/ui-egui/src/tests/contextmenu.rs | 40 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index 0151b3558..67f6ac861 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -175,18 +175,25 @@ pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option<c // The topmost type whose glyphs `p` is on, and whether it or a layer or group holding it is // locked: a word under other type isn't the one right-clicked, whether that type's word there // is right or not. - let mut top: Option<(&Node, bool)> = None; - fn walk<'a>(nodes: &'a [std::sync::Arc<Node>], locked: bool, f: &mut impl FnMut(&'a Node, bool)) { + let mut top: Option<(vectorcraft_doc::NodeId, bool)> = None; + // Back to front as drawn at `p`: an Intertwine group's members by their tier there, those of + // one tier in their stacking order ([`vectorcraft_doc::intertwine`]). + fn walk(nodes: &[std::sync::Arc<Node>], p: Point, locked: bool, f: &mut impl FnMut(&Node, bool)) { for n in nodes.iter().filter(|n| n.visible) { let locked = locked || n.locked; f(n, locked); - if let Some(children) = n.children() { - walk(children, locked, f); + let Some(children) = n.children() else { continue }; + if matches!(n.kind, NodeKind::Intertwine { .. }) { + let mut drawn = children.to_vec(); + drawn.sort_by_key(|c| vectorcraft_doc::intertwine::tier_at(c, p)); + walk(&drawn, p, locked, f); + } else { + walk(children, p, locked, f); } } } - // Paint order: a later hit is on top. - walk(&st.doc.layers, false, &mut |n, locked| { + // A later hit is on top. + walk(&st.doc.layers, p, false, &mut |n, locked| { let NodeKind::Text(tx) = &n.kind else { return }; if n.geometric_bounds().is_none_or(|b| !b.contains(p)) { return; @@ -194,10 +201,11 @@ pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option<c let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); let on = vectorcraft_text::selection_quads(&lay, 0, tx.plain_text().len()).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)); if on { - top = Some((n, locked)); + top = Some((n.id, locked)); } }); - let (n, locked) = top?; + let (id, locked) = top?; + let n = st.doc.node(id)?; let NodeKind::Text(tx) = &n.kind else { return None }; if locked || !auto_spell_checks(n) { return None; @@ -207,7 +215,6 @@ pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option<c let w = words .into_iter() .find(|w| vectorcraft_text::selection_quads(&lay, w.range.start, w.range.end).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)))?; - let id = n.id; let word = tx.plain_text().get(w.range.clone())?.to_string(); let uid = st.uid; let suggestions = app diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index 830da939d..26e04961b 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -528,3 +528,43 @@ fn a_word_under_other_type_is_not_the_one_right_clicked() { let texts = click(&mut app, &ctx, p, PointerButton::Secondary); assert!(!has(&texts, "Ignore All"), "under right type: {texts:?}"); } + +/// In an Intertwine group, the type drawn on top at the right-click is the one whose word is +/// offered: a member brought to the front there, though earlier in the stacking order. +#[test] +fn in_an_intertwine_group_the_type_drawn_on_top_is_the_one_right_clicked() { + let mut app = app(); + let under = spelled(&mut app, "Qzvxtrwk"); + let over = + NodeId(app.run("text.create", json!({"x": 20, "y": 60, "text": "the cat sat on the mat", "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [under.0, over.0]})).unwrap(); + let g = NodeId(app.run("object.intertwine.make", json!({})).unwrap()["id"].as_u64().unwrap()); + app.select_tool("selection"); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + // A point on the word where glyphs of both overlap: Intertwine's areas follow the outlines. + let quad = { + let st = app.session.active().unwrap(); + let Some(NodeKind::Text(t)) = st.doc.node(under).map(|n| &n.kind) else { panic!("type") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + vectorcraft_text::selection_quads(&lay, 0, 8)[0].map(|c| t.xf * c) + }; + let (x0, x1) = (quad.iter().map(|c| c.x).fold(f64::MAX, f64::min), quad.iter().map(|c| c.x).fold(f64::MIN, f64::max)); + let (y0, y1) = (quad.iter().map(|c| c.y).fold(f64::MAX, f64::min), quad.iter().map(|c| c.y).fold(f64::MIN, f64::max)); + let overlap = (0..40) + .flat_map(|i| (0..20).map(move |j| Point::new(x0 + (x1 - x0) * (i as f64 + 0.5) / 40.0, y0 + (y1 - y0) * (j as f64 + 0.5) / 20.0))) + .find(|q| app.run("object.intertwine.arrange", json!({"id": g.0, "at": [q.x, q.y], "order": "back", "member": under.0})).is_ok()) + .expect("glyphs of both overlap somewhere on the word"); + let p = screen(&app, overlap.x, overlap.y); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "under the right type: {texts:?}"); + frame( + &mut app, + &ctx, + vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + app.run("object.intertwine.arrange", json!({"id": g.0, "at": [overlap.x, overlap.y], "order": "front", "member": under.0})).unwrap(); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(has(&texts, "Ignore All"), "brought to the front there: {texts:?}"); +} From 156613941af6144a9dd115444e0d4b705f83f268 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 21:53:22 -0800 Subject: [PATCH 35/48] Type: glyph bounds take in underline and strikethrough bars With Measure Type by Glyph Bounds on, point type is measured by its glyph outlines and its underline and strikethrough bars, as the canvas draws them. A typed W or H gives what is drawn that size, and underlined or struck-through spaces are measured by their bars instead of the em box. The texts now say that each point type object selected as an object (not inside a selected group) is measured this way, that moved type snaps by its ink box, and which Arrange Tools commands follow the option: Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects. Arrange in Grid and Group Overlapping Objects keep measuring visual bounds. --- crates/doc/src/text.rs | 3 +- crates/effects/src/preview.rs | 18 +++++++++--- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/typecmd.rs | 4 +-- crates/engine/src/prefs.rs | 5 ++-- crates/engine/src/tests/typeglyphbounds.rs | 32 ++++++++++++++++++++-- crates/tools/src/context.rs | 5 ++-- docs/control-protocol.md | 3 +- docs/mcp.md | 16 +++++++---- docs/target-app-parity.md | 2 +- 10 files changed, 69 insertions(+), 21 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 2d1a60e59..74aa5f444 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -121,7 +121,8 @@ pub struct TextObject { /// vertical column's centre line), filled in with `cached_bounds` (not serialized). #[serde(skip)] pub cached_baselines: Vec<(Point, Point)>, - /// Cached ink of the glyphs in text space (`TextLayout::ink_bounds`, `None` when nothing draws), + /// Cached ink of the glyphs in text space, underline and strikethrough bars included + /// (`vectorcraft_effects::preview::laid_out_ink`, `None` when nothing draws), /// with the `cached_bounds` it was measured with: filled in with `cached_bounds` by the engine's /// text edits, and stale once `cached_bounds` differs ([`Self::ink`]). Not serialized. #[serde(skip)] diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index ee8d4e1c1..ad36ab56d 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -165,8 +165,9 @@ fn measured_in(drawn: &Drawn, n: &Node, a: Affine, m: Measure) -> Option<Rect> { /// The box of the ink of type `t`'s glyphs with its text space mapped by `a * t.xf`: the cached /// ink ([`vectorcraft_doc::TextObject::ink`]) when that map keeps a rectangle square, else the -/// glyph outlines mapped point by point ([`vectorcraft_text::TextLayout::ink_bounds_in`]). `None` -/// when nothing draws (no text, only spaces, or glyphs without outlines). +/// glyph outlines and the underline and strikethrough bars mapped point by point +/// ([`vectorcraft_text::envelope_ink`]). `None` when nothing draws (no text, only spaces without +/// underline or strikethrough, or glyphs without outlines). pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option<Rect> { let m = a * t.xf; let [p, q, r, s, _, _] = m.as_coeffs(); @@ -174,11 +175,20 @@ pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option<Rect> { if square { let ink = match t.ink() { Some(ink) => ink, - None => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds(), + None => vectorcraft_text::envelope_ink(t, Affine::IDENTITY, None), }; return ink.map(|i| m.transform_rect_bbox(i)); } - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds_in(m) + vectorcraft_text::envelope_ink(t, m, None) +} + +/// The ink of type `t` laid out as `layout`, in text space: its glyphs +/// ([`vectorcraft_text::TextLayout::ink_bounds`]) and its underline and strikethrough bars +/// ([`vectorcraft_text::decorations`]), what [`text_ink_in`] measures. `None` when nothing draws. +pub fn laid_out_ink(layout: &vectorcraft_text::TextLayout, t: &vectorcraft_doc::TextObject) -> Option<Rect> { + vectorcraft_text::decorations(layout, vectorcraft_text::FontDb::global(), t) + .into_iter() + .fold(layout.ink_bounds(), |acc, (_, bar)| union_opt(acc, Some(bar.bounding_box()))) } /// The visual bounds of `n` as drawn: those of its [`drawn_art`], else diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 086bf3843..2f0aa742d 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -287,7 +287,7 @@ pub fn specs() -> Vec<CommandSpec> { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, point type selected on its own is measured by its glyphs' ink, which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, each point type object selected as an object (not inside a selected group) is measured by its glyphs' ink (underline and strikethrough included), which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 1bf212717..a4fe33762 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -304,7 +304,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); - t.cached_ink = Some((lay.bounds, lay.ink_bounds())); + t.cached_ink = Some((lay.bounds, vectorcraft_render::effects::preview::laid_out_ink(&lay, t))); } /// [`refresh_bounds`] for a document that is opening: the first frame takes the layout @@ -316,7 +316,7 @@ pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); - t.cached_ink = Some((lay.bounds, lay.ink_bounds())); + t.cached_ink = Some((lay.bounds, vectorcraft_render::effects::preview::laid_out_ink(&lay, t))); vectorcraft_text::keep_layout(t, stamp, lay); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index e6fa7cb8d..d6fd21d28 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -43,8 +43,9 @@ pub struct Prefs { pub align_glyph_bounds_area: bool, /// Preferences › Type › Glyph Bounds › Measure Type by Glyph Bounds (also in the Transform /// panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, - /// Y, W and H, and `object.setBounds` measure point type selected on its own by the ink of its - /// glyphs instead of its em box ([`crate::Session::bounds_measure`]). + /// Y, W and H, and `object.setBounds` measure each point type object selected as an object (not + /// inside a selected group) by the ink of its glyphs instead of its em box + /// ([`crate::Session::bounds_measure`]). pub type_glyph_bounds: bool, /// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the /// canvas draws everything outside the artboards darker, the pasteboard and the art on it. diff --git a/crates/engine/src/tests/typeglyphbounds.rs b/crates/engine/src/tests/typeglyphbounds.rs index 0f42fcc7f..9fd8e400b 100644 --- a/crates/engine/src/tests/typeglyphbounds.rs +++ b/crates/engine/src/tests/typeglyphbounds.rs @@ -1,6 +1,6 @@ //! Preferences › Type › Measure Type by Glyph Bounds (`typeGlyphBounds`): the bounding box, the -//! Transform panel's values and `object.setBounds` measure point type selected on its own by the -//! ink of its glyphs instead of its em box. +//! Transform panel's values and `object.setBounds` measure each point type object selected as an +//! object (not inside a selected group) by the ink of its glyphs instead of its em box. use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, OrientedBox, TextObject}; @@ -282,6 +282,34 @@ fn saved_documents_do_not_depend_on_the_preference() { assert_eq!(serde_json::to_string(&s.doc().unwrap().doc).unwrap(), off); } +/// What the type draws on the page: its glyphs and its underline and strikethrough bars. +fn drawn(s: &Session, id: NodeId) -> Rect { + let t = text(s, id); + vectorcraft_text::envelope_ink(&t, t.xf, None).unwrap() +} + +/// Underline and strikethrough bars are drawn with the type, so the box takes them in and a typed +/// height gives what is drawn that height. Underlined spaces are measured by their bar. +#[test] +fn underline_and_strikethrough_bars_count_as_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "H"})); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 1, "underline": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, drawn(&s, t), 1e-9), "{b:?} != {:?}", drawn(&s, t)); + assert!(b.y1 > ink(&s, t).y1 + 1.0, "the underline hangs below the glyph"); + run(&mut s, "object.setBounds", json!({"height": 50})); + assert!((drawn(&s, t).height() - 50.0).abs() < 1e-6, "{:?}", drawn(&s, t)); + assert!(near(s.transform_box(&[t]).unwrap().rect, drawn(&s, t), 1e-6)); + + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 40, "text": " "})); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 3, "strikethrough": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, drawn(&s, t), 1e-9), "{b:?} != {:?}", drawn(&s, t)); + assert!(em(&s, t).height() > b.height() + 10.0, "em box {:?}", em(&s, t)); +} + /// A transform of type keeps its cached ink, which is in text space. #[test] fn the_cached_ink_is_the_laid_out_ink() { diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 971323bf1..741e96229 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -178,8 +178,9 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, - /// Type › Measure Type by Glyph Bounds: the bounding box measures point type selected on its - /// own by the ink of its glyphs ([`vectorcraft_effects::preview::Measure`]). + /// Type › Measure Type by Glyph Bounds: the bounding box measures each point type object + /// selected as an object (not inside a selected group) by the ink of its glyphs + /// ([`vectorcraft_effects::preview::Measure`]). pub glyph_bounds: bool, /// The session's memo of the selection tools' measurements for the document as it is /// ([`select::PreviewMemo`]); without one they measure on every call. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b480106b3..bcf7644d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -531,7 +531,8 @@ Align to Glyph Bounds: the Align panel flyout's Align to Glyph Bounds submenu sh Measure Type by Glyph Bounds: the Transform panel menu's Measure Type by Glyph Bounds item shows and toggles the `typeGlyphBounds` preference (`prefs.set`), checked while on; while it is on, the bounding box and the Transform -panel, Properties and Control bar fields measure point type selected on its own by its glyphs' ink. +panel, Properties and Control bar fields measure each point type object selected as an object (not inside a selected +group) by its glyphs' ink. Align To: the Align panel's Align To choice is saved with the preferences, and `ui.inspect` reports it as `ui.align_to` (`"selection"` or `"artboard"`; Align to Key Object stores no choice). The align buttons of the Align diff --git a/docs/mcp.md b/docs/mcp.md index c33243f42..d22e27342 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3969,17 +3969,23 @@ The bounding box normally measures point type by its em box, which leaves room a ascenders, descenders and capitals. With the preference `typeGlyphBounds` on (`prefs.set {key: "typeGlyphBounds", value: true}`; also Preferences › Type › Glyph Bounds and the Transform panel menu's Measure Type by Glyph Bounds), the bounding box and its handles, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, -`object.setBounds`, the Scale, Rotate and Reflect tools' reference point and the Arrange commands measure point type -selected on its own, horizontal or vertical, by the ink of its glyphs. The option is VectorCraft's own and is off by +`object.setBounds`, the Scale, Rotate and Reflect tools' reference point, and Swap Objects, Replace with Top Object, +Set Size of Each and Sort Objects measure point type, horizontal or vertical, by the ink of its glyphs: its glyph +outlines and its underline and strikethrough bars. This applies to each point type object that is selected as an object +(not inside a selected group), in a selection with other objects too. The option is VectorCraft's own and is off by default. A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that -draws nothing (no text, only spaces), area type, type on a path and type inside a selected group are measured as -before. Align and Distribute follow Align to Glyph Bounds, and snapping and clicks do not change. The preference is -not saved in documents. +draws nothing (no text, or only spaces without underline or strikethrough), area type, type on a path and type inside +a selected group are measured as before. Arrange in Grid and Group Overlapping Objects measure visual bounds as +before, and Align and Distribute follow Align to Glyph Bounds. + +When moved type snaps (Snap to Grid, Snap to Pixel, Smart Guides and ruler guides), the edges and center of its ink box +snap, and the Distance Guides measure from that box. The other objects that it snaps to keep their usual boxes, as +they do with Use Preview Bounds. The preference is not saved in documents. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeGlyphBounds":true}}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2140f5410..c769dafd2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1052,7 +1052,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds` and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). + - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. The Lights list, Add Light and Delete Light manage one to ten independent colored lights; ambient is shared and visibility remains approximate ([revolve.md](revolve.md)). From cc03fade6e8d18c10846b9c3a602a6fdef703b0c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 22:00:04 -0800 Subject: [PATCH 36/48] Scripts: the growth budget counts every art store, with CLI and source fuzz tests A document a script opens now counts the art in its graph designs and in the original of the pattern being edited toward the growth allowance. `Document::art_roots` and `art_roots_mut` list every tree of a document's art; `any_node`, `edit_nodes_where` and the script's object count use them. `script.run` checks the depth of `args` without recursion before it copies or writes them out, so `args` nested 10,000 levels deep is refused with the place it went past 64 levels instead of overflowing the stack. New tests: graph-design art and the growth allowance, `args` nested 10,000 levels deep from a script (a RangeError) and from a caller, `run --cmd script.run` on the command line with a report and with a thrown error, and a property test that sends arbitrary source through `script.run`. --- apps/vectorcraft-cli/tests/it/main.rs | 1 + apps/vectorcraft-cli/tests/it/script.rs | 39 +++++++ crates/doc/src/document_ext.rs | 38 ++++--- crates/engine/src/cmd/scripts.rs | 4 + crates/engine/src/script/mod.rs | 47 +++++++- crates/engine/src/tests/script_run.rs | 139 ++++++++++++++++++++++++ crates/js/src/host_tests.rs | 11 ++ 7 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 apps/vectorcraft-cli/tests/it/script.rs diff --git a/apps/vectorcraft-cli/tests/it/main.rs b/apps/vectorcraft-cli/tests/it/main.rs index 6918ea19b..07e5a3a70 100644 --- a/apps/vectorcraft-cli/tests/it/main.rs +++ b/apps/vectorcraft-cli/tests/it/main.rs @@ -5,3 +5,4 @@ mod cli; mod fileio; mod mcp_e2e; +mod script; diff --git a/apps/vectorcraft-cli/tests/it/script.rs b/apps/vectorcraft-cli/tests/it/script.rs new file mode 100644 index 000000000..d68716f7b --- /dev/null +++ b/apps/vectorcraft-cli/tests/it/script.rs @@ -0,0 +1,39 @@ +use std::process::Command; + +use serde_json::{Value, json}; + +const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); + +/// `run` with `script.run` after `file.new`, with `params`. +fn run_script(params: &Value) -> std::process::Output { + Command::new(BIN).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", ¶ms.to_string()]).output().unwrap() +} + +/// `run` runs a script headless: the reply holds the report, with what the script logged and the +/// documents it changed. +#[test] +fn run_gives_the_report_of_a_script() { + let source = "vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10}); vc.log(vc.args.n * 2);"; + let out = run_script(&json!({"source": source, "name": "double.js", "args": {"n": 21}})); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 2, "{lines:?}"); + assert_eq!(lines[1]["command"], "script.run"); + let report = &lines[1]["result"]; + assert_eq!(report["ok"], true, "{report}"); + assert_eq!(report["name"], "double.js"); + assert_eq!(report["log"], json!([42]), "{report}"); + assert_eq!(report["changed"].as_array().map(Vec::len), Some(1), "{report}"); +} + +/// A script that throws fails its step with the script's message, and the steps before it ran. +#[test] +fn run_fails_the_step_of_a_script_that_throws() { + let out = run_script(&json!({"source": "vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10}); throw new Error('no margin given');"})); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("step 1 (script.run)") && stderr.contains("no margin given"), "{stderr}"); + let printed: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(printed.len(), 1, "only file.new printed: {printed:?}"); + assert_eq!(printed[0]["command"], "file.new"); +} diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index c40dcf9f2..ebb2fc44e 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -123,18 +123,33 @@ impl Document { std::borrow::Cow::Owned(d) } - /// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions, - /// graph designs, pattern swatches (the one being edited included) and their opacity masks? - pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool { - let roots = self - .layers + /// The roots of every tree of the document's art: the layers, symbol definitions, graph designs, + /// pattern swatches and the original of the pattern being edited. Opacity masks hang off nodes + /// in these trees. + pub fn art_roots(&self) -> impl Iterator<Item = &Arc<Node>> { + self.layers .iter() .chain(self.symbols.iter().map(|s| &s.art)) .chain(self.graph_designs.iter().map(|g| &g.art)) .chain(self.patterns.iter().flat_map(|p| p.art.iter())) - .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())); + .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())) + } + + /// [`Self::art_roots`], to change. + pub fn art_roots_mut(&mut self) -> impl Iterator<Item = &mut Arc<Node>> { + self.layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())) + } + + /// Does `wants` hold for a node anywhere in the document's art ([`Self::art_roots`]) or its + /// opacity masks? + pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool { let mut any = false; - for root in roots { + for root in self.art_roots() { crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n)); } any @@ -144,14 +159,7 @@ impl Document { /// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art /// they share. pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) { - let roots = self - .layers - .iter_mut() - .chain(self.symbols.iter_mut().map(|s| &mut s.art)) - .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) - .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) - .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); - for root in roots { + for root in self.art_roots_mut() { edit_branches_where(root, &wants, &mut f); } } diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 692de92f3..b8b544881 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -92,6 +92,10 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> { Some(_) => Err(param(format!("`{key}` must be a string"))), }; let (path, source, given_name) = (optional_str("path")?, optional_str("source")?, optional_str("name")?); + // Checked before anything copies or writes out `args`, which would go one call deeper per level. + if let Some(place) = p.get("args").and_then(script::too_deep) { + return Err(param(format!("`args{place}` is nested more than 64 levels deep"))); + } let args = match p.get("args") { None => serde_json::Map::new(), Some(Value::Object(o)) => o.clone(), diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 643f7ea31..632d45de0 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -503,6 +503,46 @@ fn to_json(d: &Data) -> Value { } } +/// A step into a JSON value: a key or an index. +enum Step<'a> { + Name(&'a str), + Index(usize), +} + +/// The place in `v` that is more than [`DataLimits`]' depth levels deep, such as `.layers[2].name`, +/// or `None`. It walks `v` with a list of its own, so any depth fits on the stack. +pub(crate) fn too_deep(v: &Value) -> Option<String> { + let limit = DataLimits::default().depth; + // `steps[i - 1]` is the entry that holds entry `i` and the step into it; entry 0 is `v`. + let mut steps: Vec<(usize, Step<'_>)> = Vec::new(); + let mut todo = vec![(v, 0usize, 0usize)]; + while let Some((v, depth, entry)) = todo.pop() { + if depth > limit { + let mut place = Vec::new(); + let mut at = entry; + while let Some((parent, step)) = at.checked_sub(1).and_then(|i| steps.get(i)) { + place.push(match step { + Step::Name(k) => format!(".{k}"), + Step::Index(i) => format!("[{i}]"), + }); + at = *parent; + } + place.reverse(); + return Some(place.concat()); + } + let children: Box<dyn Iterator<Item = (Step<'_>, &Value)>> = match v { + Value::Array(a) => Box::new(a.iter().enumerate().map(|(i, x)| (Step::Index(i), x))), + Value::Object(o) => Box::new(o.iter().map(|(k, x)| (Step::Name(k.as_str()), x))), + _ => continue, + }; + for (step, child) in children { + steps.push((entry, step)); + todo.push((child, depth + 1, steps.len())); + } + } + None +} + /// Estimated bytes of `d`. fn data_size(d: &Data) -> u64 { match d { @@ -527,12 +567,9 @@ fn raise_ids(s: &mut Session) { s.script_high_water = marks; } -/// Every object in `d`: the art of its layers, its symbols and its patterns, opacity masks included. +/// Every object in `d`: all its art ([`crate::Document::art_roots`]), opacity masks included. fn all_objects(d: &crate::Document) -> u64 { - let layers = d.layers.iter().map(|n| n.count_with_masks()); - let symbols = d.symbols.iter().map(|s| s.art.count_with_masks()); - let patterns = d.patterns.iter().flat_map(|p| &p.art).map(|n| n.count_with_masks()); - layers.chain(symbols).chain(patterns).map(|n| n as u64).sum() + d.art_roots().map(|n| n.count_with_masks() as u64).sum() } fn note_ids(s: &mut Session) { diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index bc3cc67a9..81b224628 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -640,6 +640,81 @@ fn a_document_a_run_opens_counts_toward_its_growth_allowance() { assert_eq!(state(&s), before); } +#[test] +fn graph_design_art_counts_toward_the_growth_allowance() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let mut s = two_docs(); + for x in 0..30 { + s.execute("shape.rectangle", &rect(x)).unwrap(); + } + let before = state(&s); + let designs = |s: &Session| s.documents().iter().map(|d| d.doc.graph_designs.len()).collect::<Vec<_>>(); + let designs_before = designs(&s); + // A design saved from the selection in an open document. + let e = run(&mut s, "vc.run('select.all'); vc.run('graph.design', {new: 'Many'});").unwrap_err().to_string(); + assert!(e.contains("objects and data"), "{e}"); + assert_eq!(state(&s), before); + assert_eq!(designs(&s), designs_before); + + // A document a run opens whose art is all in a graph design. + let mut maker = Session::new(); + maker.execute("file.new", &json!({})).unwrap(); + let ids: Vec<u64> = (0..30).map(|x| maker.execute("shape.rectangle", &rect(x)).unwrap()["id"].as_u64().unwrap()).collect(); + maker.execute("select.all", &json!({})).unwrap(); + maker.execute("graph.design", &json!({"new": "Many"})).unwrap(); + maker.execute("edit.clear", &json!({"ids": ids})).unwrap(); + assert_eq!(objects(&maker, 0), 0); + assert_eq!(maker.documents()[0].doc.graph_designs[0].art.count_with_masks(), 31); + let data = maker.execute("document.serialize", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let source = format!("vc.run('document.open', {{name: 'g.vectorcraft', dataBase64: '{data}'}});"); + let e = run(&mut s, &source).unwrap_err().to_string(); + TEST_GROWTH.with(|g| g.set(None)); + assert!(e.contains("objects and data"), "{e}"); + assert_eq!(state(&s), before); +} + +/// Drops `v` one level at a time. +fn drop_flat(v: Value) { + let mut stack = vec![v]; + while let Some(mut v) = stack.pop() { + match &mut v { + Value::Object(o) => stack.extend(std::mem::take(o).into_iter().map(|(_, x)| x)), + Value::Array(a) => stack.append(a), + _ => {} + } + } +} + +#[test] +fn args_nested_ten_thousand_deep_in_a_script_throw_a_range_error_with_their_place() { + let mut s = two_docs(); + let before = state(&s); + let source = "var v = 1; for (var i = 0; i < 10000; i++) v = {a: v}; var m = 'none'; \ + try { vc.run('script.run', {source: '', args: v}); } catch (e) { m = e.name + '|' + e.message; } vc.log(m);"; + let r = run(&mut s, source).unwrap(); + let m = r["log"][0].as_str().unwrap_or_default(); + assert!(m.starts_with("RangeError|") && m.contains(".args.a.a.a") && m.contains("nested too deep"), "{r}"); + assert_eq!(state(&s), before); +} + +#[test] +fn args_nested_ten_thousand_deep_from_a_caller_are_refused_with_their_place() { + let mut s = two_docs(); + let before = state(&s); + // Built by moving each level in: `json!` would copy the value through `to_value`, one call per level. + let wrap = |key: &str, v: Value| Value::Object(std::iter::once((key.to_string(), v)).collect()); + let mut deep = json!(1); + for _ in 0..10_000 { + deep = wrap("a", deep); + } + let mut params = wrap("args", wrap("b", deep)); + params["source"] = json!(""); + let e = s.execute("script.run", ¶ms).unwrap_err().to_string(); + drop_flat(params); + assert!(e.contains("`args.b.a.a.a") && e.contains("nested more than 64 levels deep"), "{e}"); + assert_eq!(state(&s), before); +} + #[test] fn a_person_can_let_a_growing_run_go_on_or_stop_it() { TEST_GROWTH.with(|g| g.set(Some(20))); @@ -765,3 +840,67 @@ fn a_cancel_from_the_caller_stops_a_headless_run() { assert!(!stop.is_set()); assert_eq!(state(&s), before); } + +/// Script text: pieces that change documents, throw or loop, mixed with arbitrary text. +fn source_text() -> impl proptest::strategy::Strategy<Value = String> { + use proptest::prelude::*; + let piece = prop_oneof![ + Just("vc.run('shape.rectangle', {x:1,y:1,width:5,height:5});".to_string()), + Just("vc.run('select.all'); vc.run('edit.clear');".to_string()), + Just("vc.run('file.new', {});".to_string()), + Just("vc.run('document.activate', {index:0});".to_string()), + Just("vc.run('no.such');".to_string()), + Just("vc.log(vc.document);".to_string()), + Just("throw new Error('x');".to_string()), + Just("vc.run('shape.rectangle', {x:2,y:2,width:5,height:5}); throw new Error('y');".to_string()), + Just("while (true) {}".to_string()), + Just("var o = {}; o.o = o; vc.run('shape.rectangle', o);".to_string()), + Just("{".to_string()), + Just("try {".to_string()), + Just("} catch (e) {}".to_string()), + "[ -~]{0,30}", + "\\PC{0,10}", + ]; + proptest::collection::vec(piece, 0..12).prop_map(|p| p.concat()) +} + +/// Every field of each open document but its next id, and the rest of what a rollback puts back. +/// A failed run raises the next id past the ids it gave out, which [`next_ids`] reads. +fn saved(s: &Session) -> (Value, Vec<String>) { + let docs = s + .documents() + .iter() + .map(|d| { + let mut doc = (*d.doc).clone(); + doc.raise_next_id(1 << 50); + format!("{doc:?}") + }) + .collect(); + (state(s), docs) +} + +fn next_ids(s: &Session) -> Vec<u64> { + s.documents().iter().map(|d| d.doc.peek_next_id()).collect() +} + +proptest::proptest! { + #![proptest_config(proptest::prelude::ProptestConfig::with_cases(48))] + + /// Any script text ends with a report or a script error, and a run that fails leaves every + /// document as it was. + #[test] + fn any_source_ends_with_a_report_or_a_script_error(source in source_text()) { + let mut s = two_docs(); + let (before, ids) = (saved(&s), next_ids(&s)); + match s.execute("script.run", &json!({"source": source, "maxFuel": 200_000})) { + Ok(r) => proptest::prop_assert_eq!(&r["ok"], &json!(true), "{}", r), + Err(e) => { + proptest::prop_assert!(matches!(e, EngineError::Other(_)), "{:?}", e); + let kind = s.script_report.as_ref().map(|r| r["kind"].clone()); + proptest::prop_assert!(kind.as_ref().is_some_and(|k| k != "internal"), "{:?}: {}", kind, e); + proptest::prop_assert_eq!(saved(&s), before); + proptest::prop_assert!(next_ids(&s).iter().zip(&ids).all(|(after, was)| after >= was), "{:?} after {:?}", next_ids(&s), ids); + } + } + } +} diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index d43e26f5b..85e178d87 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -171,6 +171,17 @@ fn values_that_are_not_plain_data_are_refused_with_their_place() { refused("var value = 'x'; for (var i = 0; i < 12; i++) value += value;", "RangeError", "too large"); } +#[test] +fn a_value_nested_ten_thousand_deep_is_refused_with_its_place() { + let mut r = realm(100_000_000); + let seen = with_take(&mut r); + let probe = "var v = 1; for (var i = 0; i < 10000; i++) v = {a: v}; var m = 'none'; \ + try { host.take({args: v}); } catch (e) { m = e.name + '|' + e.message; } m"; + let JsValue::String(out) = r.run(probe).unwrap() else { panic!("not a string") }; + assert!(out.starts_with("RangeError|") && out.contains(".args.a.a.a") && out.contains("nested too deep"), "{out}"); + assert!(seen.borrow().is_empty(), "the function ran"); +} + #[test] fn a_shared_object_is_allowed_and_sharing_cannot_multiply_the_work() { let mut r = realm(10_000_000); From bd0515ccc8b75f82b535e7df7e745366b5097569 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 22:16:58 -0800 Subject: [PATCH 37/48] SVG export: mask and filter regions cover the art they apply to Opacity masks, inverted and unclipped masks, and knockout groups were written with mask and filter regions of 200,000 x 200,000 units around the origin. CairoSVG draws each mask into an image of its region's size and stops with "out of memory", so a file with any of them did not open; Firefox drew inverted masks wrong and WebKit drew knockout groups and inverted masks wrong. Each region now covers what the masked or knocked-out art paints: its visual box grown by its raster effects, brush art and members' reach, the mask art's box, and in a scaled symbol instance its strokes and effects at their own size. A mask whose art reaches past its object still comes back whole on import. --- crates/svg/src/export.rs | 13 ++- crates/svg/src/export/nodes.rs | 137 +++++++++++++++++++++++--- crates/svg/tests/it/blend_export.rs | 63 ++++++++++++ crates/svg/tests/it/mask_options.rs | 21 ++++ crates/svg/tests/it/symbols_layers.rs | 25 +++++ docs/file-format-parity.md | 2 +- 6 files changed, 243 insertions(+), 18 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 13c426847..d671522f5 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -98,7 +98,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: brushes: None, knockout: doc.page_knockout, anonymous: false, - knockout_filter: None, + knockout_filters: HashMap::new(), + region_boxes: std::cell::OnceCell::new(), + art_reach: std::cell::RefCell::default(), warnings: vec![], id_prefix, linked: Vec::new(), @@ -255,8 +257,13 @@ struct Writer<'a> { knockout: bool, /// Writing a copy of an object (a knockout mask): no object ids, so they stay unique. anonymous: bool, - /// The filter that paints art black keeping its alpha (knockout masks), once defined. - knockout_filter: Option<String>, + /// The filters that paint art black keeping its alpha (knockout masks), by the region + /// attributes they were defined with. + knockout_filters: HashMap<String, String>, + /// The document's symbol boxes, measured once for every mask and filter region. + region_boxes: std::cell::OnceCell<vectorcraft_doc::SymbolBoxes<'a>>, + /// How far each symbol's art paints past its box, by name, once measured for a region. + art_reach: std::cell::RefCell<HashMap<String, f64>>, /// The brush library, parsed when the first brushed stroke is written. brushes: Option<Vec<vectorcraft_brush::Brush>>, /// Features written approximately (once each). diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index adcd3fd25..b31fa1099 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_valid}; use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; @@ -143,9 +145,12 @@ fn pin_to_page(n: &mut Node, m: Affine) { const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups"; pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; -/// A region covering any artwork (mask and filter extents). +/// The region of a mask or filter over art that has no box to measure: one covering any artwork. const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; +/// How deep [`Writer::paint_reach`] looks into members and symbol art. +const MAX_REACH_DEPTH: u32 = 64; + /// Attributes of every `<mask>`: user-space units, and luminance taken from the sRGB values (as the /// canvas and PDF take it) rather than from linearised ones. pub(super) const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; @@ -354,13 +359,18 @@ impl Writer<'_> { return self.node_body(&bare); } let mid = self.fresh_id("mask"); + let mut bare = n.clone(); + bare.extras_mut().mask = None; + // The region covers what the object and the mask art paint: readers draw the mask into an + // image of the region's size. + let region = self.region_attrs(&[&bare, &m.art]); // Mask art is a picture of its own: it takes no part in a knockout group around the object. let knockout = std::mem::take(&mut self.knockout); let art = self.detached(2, |w| w.node(&m.art)); self.knockout = knockout; let inv = m.invert.then(|| { let fid = self.fresh_id("invert"); - self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {BIG}>")); + self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {region}>")); self.def(2, "<feColorMatrix type=\"matrix\" values=\"-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0\"/>"); self.def(1, "</filter>"); fid @@ -368,13 +378,13 @@ impl Writer<'_> { // The mask's options, so import restores them (and its art without backdrop and filter). let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; - self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}{data}>")); + self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}{data}>")); if let Some(fid) = &inv { self.def(1, &format!("<g filter=\"url(#{fid})\">")); } if !m.clip || m.invert { let c = if m.clip { "black" } else { "white" }; - self.def(1, &format!("<rect {BIG} fill=\"{c}\"/>")); + self.def(1, &format!("<rect {region} fill=\"{c}\"/>")); } self.defs.push_str(&art); if inv.is_some() { @@ -383,8 +393,6 @@ impl Writer<'_> { self.def(1, "</mask>"); self.line(&format!("<g mask=\"url(#{mid})\">")); self.depth += 1; - let mut bare = n.clone(); - bare.extras_mut().mask = None; self.node_body(&bare); self.depth -= 1; self.line("</g>"); @@ -399,6 +407,102 @@ impl Writer<'_> { std::mem::replace(&mut self.body, body) } + /// The `x`, `y`, `width` and `height` of a mask or filter region over `nodes`, in the current + /// user space: the box of everything they paint ([`Self::paint_reach`]), 2 larger on each + /// side for antialiasing. [`BIG`] when none of them has a box, or when one nests deeper than + /// [`MAX_REACH_DEPTH`]. The art of a symbol instance written in place keeps its strokes and + /// effects at their own size while the instance scales its geometry, so the region also covers + /// the geometry's box, placed, grown by the strokes' and effects' reach unscaled. + fn region_attrs(&self, nodes: &[&Node]) -> String { + let symbols = self.region_boxes.get_or_init(|| self.doc.symbol_boxes()); + let mut art_reach = self.art_reach.borrow_mut(); + let mut painted: Option<Rect> = None; + for n in nodes { + let Some(v) = n.visual_bounds_with(symbols) else { continue }; + let Some(r) = self.paint_reach(symbols, &mut art_reach, n, v, 0) else { + return BIG.to_string(); + }; + let g = n.geometric_bounds_with(symbols).unwrap_or(v); + let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max); + let scaled = self.xf.transform_rect_bbox(v.inflate(r, r)); + let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r); + let both = scaled.union(unscaled); + painted = Some(painted.map_or(both, |p| p.union(both))); + } + let Some(b) = painted.map(|b| b.inflate(2.0, 2.0)).filter(|b| b.is_finite()) else { + return BIG.to_string(); + }; + format!("x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())) + } + + /// How far what `n` paints reaches past its visual box `b`: the reach of its own raster effects + /// and of those of its fills and strokes (measured as [`Self::open_filters`] measures them), + /// how far brush art can paint past its brushed strokes (`16 × w + 8` for the widest brushed + /// stroke `w`, as the canvas allows for it), and the most that one of its members or its + /// symbol's art reaches past its own box, spread by its own effects. `art_reach` keeps each + /// symbol's art reach by name once measured, so art used many times is measured once. `None` + /// when members or symbol art nest deeper than [`MAX_REACH_DEPTH`]. + fn paint_reach( + &self, + symbols: &vectorcraft_doc::SymbolBoxes, + art_reach: &mut HashMap<String, f64>, + n: &Node, + b: Rect, + depth: u32, + ) -> Option<f64> { + use vectorcraft_effects::RasterFx; + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = 18.0 / self.doc.raster_ppi(); + let reach = |effects: &[vectorcraft_doc::Effect]| -> f64 { + vectorcraft_effects::raster_effects(effects) + .iter() + .map(|f| f.outset(b) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }) + .sum() + }; + let items = n.appearance.items.iter().map(|i| reach(i.effects())).fold(0.0, f64::max); + let brush = if vectorcraft_brush::has_brush(n) { + let w = + n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max); + 16.0 * w + 8.0 + } else { + 0.0 + }; + let own = reach(&n.appearance.effects) + items + brush; + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => self.doc.symbols.iter().find(|s| s.name == *symbol).map(|s| (s, xf)), + _ => None, + }; + if n.children().is_none_or(|c| c.is_empty()) && symbol.is_none() { + return Some(own); + } + if depth >= MAX_REACH_DEPTH { + return None; + } + let mut inner: f64 = 0.0; + for c in n.children().into_iter().flatten() { + if let Some(cb) = c.visual_bounds_with(symbols) { + inner = inner.max(self.paint_reach(symbols, art_reach, c, cb, depth + 1)?); + } + } + if let Some((s, xf)) = symbol { + let art = match art_reach.get(&s.name) { + Some(r) => *r, + None => { + let r = match s.art.visual_bounds_with(symbols) { + Some(ab) => self.paint_reach(symbols, art_reach, &s.art, ab, depth + 1)?, + None => 0.0, + }; + art_reach.insert(s.name.clone(), r); + r + } + }; + // The art's reach, measured in symbol space, as far as the instance can stretch it. + let [xx, xy, yx, yy, _, _] = xf.as_coeffs(); + inner = inner.max(art * xx.hypot(xy).max(yx.hypot(yy)).max(1.0)); + } + Some(own + inner) + } + /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. fn group_props(&self, n: &Node) -> Props { let mut p = css::transparency(n); @@ -450,9 +554,12 @@ impl Writer<'_> { written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c)))); } let after = self.symbol_nodes; + // Each mask covers what the group's elements paint. + let all: Vec<&Node> = elements.iter().map(|c| &***c).collect(); + let region = self.region_attrs(&all); for (c, (before, _)) in rest.iter().zip(&written).rev() { self.symbol_nodes = *before; - let mid = self.knockout_mask(c); + let mid = self.knockout_mask(c, ®ion); self.line(&format!("<g mask=\"url(#{mid})\">")); self.depth += 1; } @@ -466,16 +573,18 @@ impl Writer<'_> { } /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object - /// opacity without its own mask, unless those define its shape). Returns the mask id. - fn knockout_mask(&mut self, c: &Node) -> String { - let filter = match &self.knockout_filter { + /// opacity without its own mask, unless those define its shape). `region` is the attributes of + /// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares. + /// Returns the mask id. + fn knockout_mask(&mut self, c: &Node, region: &str) -> String { + let filter = match self.knockout_filters.get(region) { Some(f) => f.clone(), None => { let f = self.fresh_id("knockout-shape"); - self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {BIG}>")); + self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {region}>")); self.def(2, "<feColorMatrix type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0\"/>"); self.def(1, "</filter>"); - self.knockout_filter = Some(f.clone()); + self.knockout_filters.insert(region.to_string(), f.clone()); f } }; @@ -490,8 +599,8 @@ impl Writer<'_> { let art = self.detached(3, |w| w.node(&shape)); self.anonymous = anonymous; let mid = self.fresh_id("knockout"); - self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>")); - self.def(2, &format!("<rect {BIG} fill=\"white\"/>")); + self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}>")); + self.def(2, &format!("<rect {region} fill=\"white\"/>")); self.def(2, &format!("<g filter=\"url(#{filter})\">")); self.defs.push_str(&art); self.def(2, "</g>"); diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index 65fb26799..0109897ca 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -138,3 +138,66 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() { let svg = export(&d, &ExportOptions::default()); assert!(svg.contains("<mask"), "{svg}"); } + +/// The `x`, `y`, `width` and `height` of each `<mask>` and `<filter>` element in `svg`. +fn regions(svg: &str) -> Vec<[f64; 4]> { + let opts = resvg::usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let xml = resvg::usvg::roxmltree::Document::parse_with_options(svg, opts).unwrap(); + xml.descendants() + .filter(|n| matches!(n.tag_name().name(), "mask" | "filter")) + .map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN))) + .collect() +} + +/// Mask and filter regions cover the art they apply to and no more: readers draw a mask into an +/// image of its region's size, and one drawn at 200,000 units square runs out of memory. +#[test] +fn mask_and_filter_regions_cover_their_art() { + let knockout = { + let mut d = Document::new(40.0, 20.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [200, 0, 0]); + let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), [0, 0, 200]); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d + }; + for (what, d) in [("clip", masked(true, false)), ("no clip, inverted", masked(false, true)), ("knockout", knockout)] { + let svg = export(&d, &ExportOptions::default()); + let found = regions(&svg); + assert!(!found.is_empty(), "{what}: {svg}"); + for [x, y, w, h] in found { + // The art is 40 × 20, with 2 to spare on each side. + assert!(x >= -2.5 && y >= -2.5 && w <= 45.0 && h <= 25.0 && w >= 40.0 && h >= 20.0, "{what}: {x} {y} {w} {h} in {svg}"); + } + } +} + +/// A mask's region reaches as far as the masked art paints: a drop shadow of the object, or of an +/// object in the masked group, shows past the object as on the canvas. +#[test] +fn a_mask_region_keeps_the_shadows_of_the_masked_art() { + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + }; + for in_group in [false, true] { + let mut d = Document::new(60.0, 60.0); + let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]); + red.appearance.effects.push(shadow.clone()); + let mut masked = if in_group { Node::group(d.alloc_id(), vec![red.into()]) } else { red }; + // An unclipped mask lets the art show outside the mask art: all of the shadow shows. + let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, masked); + let svg = export(&d, &ExportOptions::default()); + let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 60, 60)); + for (x, y) in [(15, 15), (40, 40), (35, 28)] { + let (c, w) = (canvas.over_white(x, y), written.over_white(x, y)); + assert!(within(c, w, 3), "in a group {in_group}, at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}"); + } + assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40)); + } +} diff --git a/crates/svg/tests/it/mask_options.rs b/crates/svg/tests/it/mask_options.rs index 541a8f702..16c021391 100644 --- a/crates/svg/tests/it/mask_options.rs +++ b/crates/svg/tests/it/mask_options.rs @@ -55,6 +55,27 @@ fn an_inverted_unclipped_mask_round_trips() { } } +/// Mask art that reaches past the masked object comes back whole: the mask's region covers the art +/// as well as the object, so import does not clip the art to the region. +#[test] +fn mask_art_larger_than_its_object_round_trips_unclipped() { + let mut d = Document::new(200.0, 200.0); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), red); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 20.0, 180.0, 170.0)), white); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let s = export(&d, &ExportOptions::default()); + let (back, warnings) = import_with_report(&s).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") }; + assert!(m.art.path_data().is_some(), "the art alone, not clipped by the region: {:?}\n{s}", m.art.kind); + let b = m.art.geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 170.0).abs() < 1e-6, "{b:?}"); +} + #[test] fn foreign_masks_keep_their_art() { // Without the attribute a white backdrop is part of the mask art (another app's file). diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs index 0fad6ff09..3e58b0228 100644 --- a/crates/svg/tests/it/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -296,6 +296,31 @@ fn masks_in_symbols_move_with_the_art_when_linked() { } } +/// A masked object in a symbol's art, drawn by an instance that scales it down: its stroke keeps +/// its weight, and the mask's region still covers all of it. +#[test] +fn a_mask_in_a_scaled_instance_covers_the_strokes_at_their_weight() { + let mut d = Document::new(120.0, 120.0); + let stroked = Appearance::basic(Paint::solid(Color::rgb8(200, 0, 0)), Paint::solid(Color::BLACK), 20.0); + let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), stroked); + let shape = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0), + ); + art.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(shape, false))); + d.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(art) }); + instance(&mut d, "Framed", Affine::translate((50.0, 50.0)) * Affine::scale(0.2)); + let svg = export(&d, &ExportOptions::default()); + let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 120, 120)); + // The stroke runs 10 either side of the 20 × 20 square's edge at (50, 50). + for (x, y) in [(43, 60), (77, 60), (60, 43), (60, 77)] { + let (c, w) = (canvas.over_white(x, y), written.over_white(x, y)); + assert!(c.iter().all(|v| *v < 60), "the stroke shows on the canvas at {x},{y}: {c:?}"); + assert!(c.iter().zip(w).all(|(a, b)| (*a as i32 - b as i32).abs() <= 8), "at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}"); + } +} + #[test] fn symbols_inside_themselves_end() { let mut d = symbol_doc(1.0, &[Affine::translate((50.0, 50.0))]); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 10dded623..ad63dbe5f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -286,7 +286,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to (`crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | From 8ae9ca2012fc3b6113e6ad21c5adc919329555ff Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 22:37:17 -0800 Subject: [PATCH 38/48] SVG export: masks over type keep the full region; live art measured expanded A mask, inverted mask or knockout group over type now keeps the region that covers any artwork. Type is measured by its layout or, on a path, by the path, and a reader that sets live type in its own font can draw it wider, so a region sized from that box could cut glyphs off. A live object (blend, envelope, repeat, mesh, placed document, intertwine) under a mask is measured as the art the writer writes for it, expanded on its own with the same limits. The writer keeps its symbol boxes and symbols' art reach in plain fields instead of cells. --- crates/svg/src/export.rs | 8 ++-- crates/svg/src/export/nodes.rs | 51 +++++++++++++++------ crates/svg/tests/it/blend_export.rs | 69 +++++++++++++++++++++++++++-- docs/file-format-parity.md | 2 +- 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index d671522f5..eed8ecd33 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -99,8 +99,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: knockout: doc.page_knockout, anonymous: false, knockout_filters: HashMap::new(), - region_boxes: std::cell::OnceCell::new(), - art_reach: std::cell::RefCell::default(), + region_boxes: None, + art_reach: HashMap::new(), warnings: vec![], id_prefix, linked: Vec::new(), @@ -261,9 +261,9 @@ struct Writer<'a> { /// attributes they were defined with. knockout_filters: HashMap<String, String>, /// The document's symbol boxes, measured once for every mask and filter region. - region_boxes: std::cell::OnceCell<vectorcraft_doc::SymbolBoxes<'a>>, + region_boxes: Option<vectorcraft_doc::SymbolBoxes<'a>>, /// How far each symbol's art paints past its box, by name, once measured for a region. - art_reach: std::cell::RefCell<HashMap<String, f64>>, + art_reach: HashMap<String, f64>, /// The brush library, parsed when the first brushed stroke is written. brushes: Option<Vec<vectorcraft_brush::Brush>>, /// Features written approximately (once each). diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index b31fa1099..129bd83e2 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -1,11 +1,9 @@ -use std::collections::HashMap; - use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_valid}; use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; use super::{ - Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, Writer, - Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, + Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, HashMap, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, + Writer, Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, }; /// A symbol's `<symbol>` def and the instance transforms that can `<use>` it. @@ -409,17 +407,27 @@ impl Writer<'_> { /// The `x`, `y`, `width` and `height` of a mask or filter region over `nodes`, in the current /// user space: the box of everything they paint ([`Self::paint_reach`]), 2 larger on each - /// side for antialiasing. [`BIG`] when none of them has a box, or when one nests deeper than - /// [`MAX_REACH_DEPTH`]. The art of a symbol instance written in place keeps its strokes and - /// effects at their own size while the instance scales its geometry, so the region also covers - /// the geometry's box, placed, grown by the strokes' and effects' reach unscaled. - fn region_attrs(&self, nodes: &[&Node]) -> String { - let symbols = self.region_boxes.get_or_init(|| self.doc.symbol_boxes()); - let mut art_reach = self.art_reach.borrow_mut(); + /// side for antialiasing. [`BIG`] when none of them has a box, when one holds type (a reader + /// sets live type in its own font, which can be wider, and type on a path is measured by its + /// path), or when one nests deeper than [`MAX_REACH_DEPTH`]. The art of a symbol instance + /// written in place keeps its strokes and effects at their own size while the instance scales + /// its geometry, so the region also covers the geometry's box, placed, grown by the strokes' + /// and effects' reach unscaled. + fn region_attrs(&mut self, nodes: &[&Node]) -> String { + let symbols = self.region_boxes.take().unwrap_or_else(|| self.doc.symbol_boxes()); + let mut art_reach = std::mem::take(&mut self.art_reach); + let region = self.region_of(&symbols, &mut art_reach, nodes); + self.region_boxes = Some(symbols); + self.art_reach = art_reach; + region + } + + /// [`Self::region_attrs`] with the document's symbol boxes and the symbols' art reach so far. + fn region_of(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap<String, f64>, nodes: &[&Node]) -> String { let mut painted: Option<Rect> = None; for n in nodes { let Some(v) = n.visual_bounds_with(symbols) else { continue }; - let Some(r) = self.paint_reach(symbols, &mut art_reach, n, v, 0) else { + let Some(r) = self.paint_reach(symbols, art_reach, n, v, 0) else { return BIG.to_string(); }; let g = n.geometric_bounds_with(symbols).unwrap_or(v); @@ -440,8 +448,10 @@ impl Writer<'_> { /// how far brush art can paint past its brushed strokes (`16 × w + 8` for the widest brushed /// stroke `w`, as the canvas allows for it), and the most that one of its members or its /// symbol's art reaches past its own box, spread by its own effects. `art_reach` keeps each - /// symbol's art reach by name once measured, so art used many times is measured once. `None` - /// when members or symbol art nest deeper than [`MAX_REACH_DEPTH`]. + /// symbol's art reach by name once measured, so art used many times is measured once. A live + /// object is measured as the art it is written as: expanded on its own, as [`Self::node`] + /// expands it, so with the same limits. `None` when `n`, its members or its symbol's art hold + /// type, or nest deeper than [`MAX_REACH_DEPTH`]. fn paint_reach( &self, symbols: &vectorcraft_doc::SymbolBoxes, @@ -451,6 +461,10 @@ impl Writer<'_> { depth: u32, ) -> Option<f64> { use vectorcraft_effects::RasterFx; + // Type draws past its measured box in a reader's own font. + if matches!(n.kind, NodeKind::Text(_)) { + return None; + } // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. let grow = 18.0 / self.doc.raster_ppi(); let reach = |effects: &[vectorcraft_doc::Effect]| -> f64 { @@ -468,6 +482,15 @@ impl Writer<'_> { 0.0 }; let own = reach(&n.appearance.effects) + items + brush; + if vectorcraft_doc::live::is_live(n) { + if depth >= MAX_REACH_DEPTH { + return None; + } + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + let Some(gb) = g.visual_bounds_with(symbols) else { return Some(own) }; + let past = [b.x0 - gb.x0, b.y0 - gb.y0, gb.x1 - b.x1, gb.y1 - b.y1].into_iter().fold(0.0, f64::max); + return Some(own + past + self.paint_reach(symbols, art_reach, &g, gb, depth + 1)?); + } let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, xf } => self.doc.symbols.iter().find(|s| s.name == *symbol).map(|s| (s, xf)), _ => None, diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index 0109897ca..3804d30ed 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -1,10 +1,11 @@ //! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the //! reference formulas when resvg draws the file, and masks take luminance the canvas's way. +use usvg::roxmltree; use vectorcraft_color::blend::blend_rgb; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, Document, Node, OpacityMask}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, OpacityMask, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; use vectorcraft_svg::{ExportOptions, export}; use vectorcraft_testkit::raster::{Image, render_artboard}; @@ -141,8 +142,7 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() { /// The `x`, `y`, `width` and `height` of each `<mask>` and `<filter>` element in `svg`. fn regions(svg: &str) -> Vec<[f64; 4]> { - let opts = resvg::usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; - let xml = resvg::usvg::roxmltree::Document::parse_with_options(svg, opts).unwrap(); + let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); xml.descendants() .filter(|n| matches!(n.tag_name().name(), "mask" | "filter")) .map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN))) @@ -201,3 +201,64 @@ fn a_mask_region_keeps_the_shadows_of_the_masked_art() { assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40)); } } + +/// Type is measured by its layout or its path, and a reader sets live type in its own font, so a +/// mask or knockout over type keeps the region that covers any artwork. +#[test] +fn mask_and_filter_regions_over_type_cover_any_artwork() { + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let text = |d: &mut Document| Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style.clone())))); + let masked = { + let mut d = Document::new(80.0, 40.0); + let mut t = text(&mut d); + let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]); + t.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, t); + d + }; + let knockout = { + let mut d = Document::new(80.0, 40.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 80.0, 40.0), [200, 0, 0]); + let mut top = text(&mut d); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d + }; + for (what, d) in [("masked type", masked), ("type in a knockout group", knockout)] { + let svg = export(&d, &ExportOptions::default()); + let found = regions(&svg); + assert!(!found.is_empty(), "{what}: {svg}"); + for [x, y, w, h] in found { + assert!(x <= -100000.0 && y <= -100000.0 && w >= 200000.0 && h >= 200000.0, "{what}: {x} {y} {w} {h} in {svg}"); + } + } +} + +/// A live object under a mask is measured as it is written, expanded: the mask's region covers +/// the drop shadows of a blend's steps. +#[test] +fn a_mask_region_covers_a_blend_as_written() { + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + }; + let mut d = Document::new(100.0, 50.0); + let mut a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), [255, 0, 0]); + let mut b = rect(&mut d, Rect::new(70.0, 10.0, 80.0, 20.0), [0, 0, 255]); + a.appearance.effects.push(shadow.clone()); + b.appearance.effects.push(shadow); + let mut blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![a.into(), b.into()], spec: Default::default() }); + let art = rect(&mut d, Rect::new(0.0, 0.0, 5.0, 5.0), [255, 255, 255]); + blend.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, blend); + let svg = export(&d, &ExportOptions::default()); + // The steps' drop shadows are filters with regions of their own; the mask's covers them all: + // the steps run from 10 to 80 across, and their shadows 15 further. + let xml = roxmltree::Document::parse_with_options(&svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); + let mask = xml.descendants().find(|n| n.has_tag_name("mask")).unwrap(); + let [x, y, w, h] = ["x", "y", "width", "height"].map(|a| mask.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(f64::NAN)); + assert!(x <= 0.0 && y <= 0.0 && x + w >= 95.0 && y + h >= 35.0 && w < 200.0 && h < 200.0, "{x} {y} {w} {h} in {svg}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ad63dbe5f..b1c305db1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -286,7 +286,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to (`crates/svg/tests/it/blend_export.rs`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | From 858e4235dbeee17d6dfa4ab80823bd4ceefc38e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 22:33:57 -0800 Subject: [PATCH 39/48] View: one Escape resets a rotated view when it has nothing else to end Escape reset a rotated view only as the second of two presses less than a second apart, and a click or another key between them started the count again. With the Rotate View tool one press reset it. Now an Escape that has nothing else to end resets the rotation. An Escape that ends something else (isolation mode, a path being drawn, a full screen mode, a dialog and the others listed in docs/target-app-parity.md) ends only that, and the next Escape resets the view, however long after it comes. Tools report a path or pick that Escape ends with `Tool::ends_on_escape`. The rotate view tests that needed two presses now check one press, and the screen mode test that named the old pair is renamed. Docs: docs/target-app-parity.md. --- crates/engine/src/tooling.rs | 6 + crates/tools/src/addons/smart_pen.rs | 5 + crates/tools/src/draw2/curvature.rs | 4 + crates/tools/src/lib.rs | 6 + crates/tools/src/meshblend.rs | 6 + crates/tools/src/pen.rs | 4 + crates/ui-egui/src/shortcuts.rs | 80 +++--- crates/ui-egui/src/shortcuts/presentation.rs | 58 +--- crates/ui-egui/src/tests/rotateview.rs | 275 +++++++++++++++---- crates/ui-egui/src/tests/screenmode.rs | 4 +- docs/target-app-parity.md | 2 +- 11 files changed, 320 insertions(+), 130 deletions(-) diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d8b32eab7..cddee4022 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1059,6 +1059,12 @@ impl Session { self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key)) } + /// Has the active tool started something that Escape ends while no button is pressed (see + /// `Tool::ends_on_escape`)? + pub fn tool_ends_on_escape(&mut self, view: ViewInfo) -> bool { + self.with_tool_cx(view, |t, cx| t.ends_on_escape(cx)) + } + pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> { // The Blend tool's first pick is not marked after an edit that dropped it. self.tell_tool_history_moved(); diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index 90c73620a..4ee448830 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -547,6 +547,11 @@ impl Tool for SmartPenTool { key == ToolKey::Tab && (self.press.is_some() || self.active(cx).is_some()) } + /// A path being drawn: Escape ends it. + fn ends_on_escape(&self, _cx: &ToolContext) -> bool { + self.path.is_some() + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> { self.stop(); self.marquee = None; diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index 70ad9a7e4..b92f8adfb 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -649,6 +649,10 @@ impl Tool for CurvatureTool { self.id = Some(cx.selection.objects[0]); } } + /// A path being drawn: Escape ends it. + fn ends_on_escape(&self, cx: &ToolContext) -> bool { + self.active(cx).is_some() + } fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { matches!(key, ToolKey::Backspace | ToolKey::Delete) && self.drag.is_none() diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 557fe52b1..16c7c7a38 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -162,6 +162,12 @@ pub trait Tool: Send { fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool { false } + /// Has the tool started something that Escape ends while no button is pressed (a path being + /// drawn, the Blend tool's first pick)? That Escape ends nothing else, such as isolation mode or + /// a rotated view. + fn ends_on_escape(&self, _cx: &ToolContext) -> bool { + false + } /// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed. fn wants_text(&self) -> bool { false diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 5edc14ad4..d64b4b162 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -153,6 +153,10 @@ impl Tool for BlendTool { fn busy(&self) -> bool { self.first.is_some() } + /// A first pick, or adding to the blend just made: Escape ends either. + fn ends_on_escape(&self, cx: &ToolContext) -> bool { + self.first(cx).is_some() + } /// Escape drops the object picked first and stops adding to the blend just made, so the next /// click picks a new first object. The document and the selection stay as they are. fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> { @@ -525,8 +529,10 @@ mod tests { let c = cx(&d, &s, &p); let mut t = BlendTool { first: None, chained: true, ..Default::default() }; assert!(t.first(&c).is_some()); + assert!(!t.busy() && t.ends_on_escape(&c), "Escape has adding to the blend to end"); assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty()); assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made"); + assert!(!t.ends_on_escape(&c), "and then has nothing to end"); assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop"); } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index e5906db59..bc564fced 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -242,6 +242,10 @@ impl Tool for PenTool { fn busy(&self) -> bool { self.drag.is_some() || self.handle.is_some() || self.convert.is_some() } + /// A path being drawn: Escape ends it. + fn ends_on_escape(&self, _cx: &ToolContext) -> bool { + self.path.is_some() + } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> { let tol = cx.tol(5.0).max(cx.point_tol()); // The path a press began is the selected one by the next event. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 63e311aea..df87187a6 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -20,7 +20,7 @@ pub(crate) use held_tool::{HeldTool, note_tool_use}; use paste::unclip_chords; pub(crate) use paste::{PasteChord, paste_command}; pub(crate) use presentation::presentation_step; -use presentation::{escape_pair_id, escape_press, escape_resets_rotation, presentation_keys}; +use presentation::{escape_resets_rotation, presentation_keys}; #[cfg(test)] use typing::is_shortcut_chord; pub(crate) use typing::keep_marked_text; @@ -201,8 +201,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::ime::controls_without_editor(app, ctx); return; } - // Taken before anything returns, so a press in a field or a dialog ends a pair of Escapes. - let mut escape_pair = escape_press(ctx); // Followed before anything returns, so a key typed in a field isn't taken for a paste. let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events)); // A numeric field being scrubbed has the keyboard: Escape cancels the drag. @@ -238,7 +236,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } if app.ui.screen_mode == 3 { crate::ime::controls_without_editor(app, ctx); - presentation_keys(app, ctx, escape_pair); + presentation_keys(app, ctx); return; } // Escape that cancels a ruler-origin drag or floating Tools move skips the tool and later Escape actions, @@ -268,7 +266,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { continue; } if k == Key::Escape && escape_ended_gesture { - remember_escape_pair(ctx, k, escape_pair); continue; } // A key the tool claims (Esc with a loaded place cursor) is only the tool's. @@ -285,7 +282,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { .unwrap_or(i.modifiers); crate::canvas::mods(m, false) }); + let ends = k == Key::Escape && app.session.tool_ends_on_escape(view); + let before = (k == Key::Escape && !ends).then(|| document_revision(app)); let r = app.session.tool_key(tk, mods, view); + // An Escape that ended something in the tool (a path being drawn, a selected point) ends + // nothing else. A hover highlight it clears does not count. + let tool_took = ends || before.is_some_and(|b| b != document_revision(app)); // Enter also takes what the tool asks of the UI: Rotate, Scale, Reflect and Shear open // their dialog with it. if claimed || (k == Key::Enter && !busy) { @@ -295,33 +297,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); } - if k == Key::Escape && !busy && !claimed && !escape_held_by_drag { - if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { - let _ = app.run("object.pattern.done", json!({})); - } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { - let _ = app.run("object.exitIsolation", json!({})); - } else if app.session.active().is_some_and(|d| d.global_edit.is_some()) { - let _ = app.run("select.globalEdit", json!({})); - } else if app.ui.flyout.is_some() { - app.ui.flyout = None; - } else if escape_pair.is_some_and(|second| escape_resets_rotation(app, ctx, second)) { - // Escape pressed twice resets a rotated view (with the Rotate View tool, once). - let _ = app.run("view.rotateReset", json!({})); - escape_pair = None; - } else if matches!(app.ui.screen_mode, 1 | 2) - && !egui::Popup::is_any_open(ctx) - && !text_focused_in_previous_frame(ctx) - && ctx.input(|i| { - i.events.iter().any(|e| { - matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) - }) - }) - { - let _ = app.run("view.screenMode", json!({ "mode": 0 })); - } + if k == Key::Escape && !busy && !claimed && !escape_held_by_drag && !tool_took { + escape_ends_one_thing(app, ctx); } - // An Escape that ended something can start a pair too. One that closed a popup cannot. - remember_escape_pair(ctx, k, escape_pair); if k == Key::Enter && !busy && !claimed { // Enter with a selection tool opens the Move dialog, as a double-click on its button // does. @@ -488,13 +466,45 @@ fn escape_ended_gesture(ctx: &egui::Context) -> bool { held } -fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option<bool>) { - if key == Key::Escape && escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { - let now = ctx.input(|i| i.time); - ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); +/// An Escape that no tool, field, popup or drag took ends the first of these that is on: pattern +/// editing, isolation mode, Global Edit, a tool flyout, a full screen mode and a rotated view. +fn escape_ends_one_thing(app: &mut VectorcraftApp, ctx: &egui::Context) { + if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { + let _ = app.run("object.pattern.done", json!({})); + } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { + let _ = app.run("object.exitIsolation", json!({})); + } else if app.session.active().is_some_and(|d| d.global_edit.is_some()) { + let _ = app.run("select.globalEdit", json!({})); + } else if app.ui.flyout.is_some() { + app.ui.flyout = None; + } else if app.session.tool_id() == "rotateView" && escape_resets_rotation(app, ctx) { + // With the Rotate View tool, the view resets before a full screen mode ends. + let _ = app.run("view.rotateReset", json!({})); + } else if matches!(app.ui.screen_mode, 1 | 2) + && !egui::Popup::is_any_open(ctx) + && !text_focused_in_previous_frame(ctx) + && ctx.input(|i| { + i.events.iter().any( + |e| matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE), + ) + }) + { + let _ = app.run("view.screenMode", json!({ "mode": 0 })); + } else if escape_resets_rotation(app, ctx) { + // An Escape that has nothing else to end resets a rotated view. After one that ended + // something, the next one does, however long after it comes. A modified Escape does not + // leave a full screen mode, so it resets the view there. + let _ = app.run("view.rotateReset", json!({})); } } +/// What a tool's Escape can change besides what [`vectorcraft_engine::Session::tool_ends_on_escape`] +/// reports: the document's revision (edits and selection changes, such as the Curvature tool +/// letting go of a selected path's current point). +fn document_revision(app: &VectorcraftApp) -> Option<u64> { + app.session.active().map(|d| d.revision) +} + /// Dispatch unmodified text keys to command bindings or tools. fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs index da746cbf5..1c447d09e 100644 --- a/crates/ui-egui/src/shortcuts/presentation.rs +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -1,45 +1,18 @@ -use super::{Key, VectorcraftApp, is_modifier, json}; +use super::{Key, VectorcraftApp, json}; -/// The second press of Escape pressed twice comes less than this many seconds after the first. -const ESCAPE_PAIR_SECONDS: f64 = 1.0; - -/// The key of the last counted Escape's time in egui's temporary data. -pub(super) fn escape_pair_id() -> egui::Id { - egui::Id::unique("escape-pair") -} - -/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or -/// has another key or pointer button press as well (a key repeat counts as another press, and the -/// modifier keys do not count). Otherwise, whether the last counted Escape came less than -/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the -/// last counted Escape's time. -pub(super) fn escape_press(ctx: &egui::Context) -> Option<bool> { - let (escape, other, now) = ctx.input(|i| { - let (mut escape, mut other) = (false, false); - for e in &i.events { - match e { - egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true, - egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true, - egui::Event::PointerButton { pressed: true, .. } => other = true, - _ => {} - } - } - (escape, other, i.time) - }); - if !escape && !other { - return None; - } - let counted = ctx.data_mut(|d| d.remove_temp::<f64>(escape_pair_id())); - (escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS)) -} - -/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, -/// neither a ruler-origin drag nor a floating Tools move is ending, and the press is the second of -/// a pair (`second`) or the Rotate View tool is chosen. -pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { +/// Does this Escape reset the view's rotation? The caller has checked that nothing earlier took +/// the press. It does when the view is rotated; no popup, modal window (a progress window, About) +/// or Appearance row popup is open; no text field had the keyboard in the last frame (egui takes +/// the focus away on Escape before this runs); no pointer button is held or let go in this frame +/// (a tab or panel drag takes the Escape, in its release frame too); and neither a ruler-origin +/// drag nor a floating Tools move is ending. +pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context) -> bool { app.view().is_some_and(|v| v.rotation != 0.0) - && (second || app.session.tool_id() == "rotateView") && !egui::Popup::is_any_open(ctx) + && !crate::panel_docking::modal_open(app, ctx) + && app.ui.transient_panel.is_none() + && !super::text_focused_in_previous_frame(ctx) + && !ctx.input(|i| i.pointer.any_down() || i.pointer.any_released()) && !crate::canvas::ruler_origin_dragging(ctx) && !crate::tools_float::escape_cancelled_move(ctx) } @@ -48,7 +21,7 @@ pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, /// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it /// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on /// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]). -pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option<bool>) { +pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context) { let (mut leave, mut steps) = (false, vec![]); ctx.input_mut(|i| { i.events.retain(|e| match e { @@ -80,11 +53,6 @@ pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, e } if leave { let _ = app.run("view.presentation", json!({})); - // The Escape that left the mode can start a pair, as in `handle`. - if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { - let now = ctx.input(|i| i.time); - ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); - } } } diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index 8f477894a..017d0175b 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -262,80 +262,259 @@ fn key(key: egui::Key, repeat: bool) -> Vec<egui::Event> { [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat, modifiers: Default::default() }).to_vec() } -/// The second of two Escape presses less than a second apart resets a rotated view. A press of -/// another key or a pointer button between them, or a longer wait, ends the pair. Shift does not. +fn selected(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().selection.objects.len() +} + +/// One Escape resets a rotated view when it has nothing else to end, with or without a selected +/// object, which stays selected. Shift held down with it (as after a Space+Shift drag) does not +/// change that, and the repeats of a held Escape do nothing more. #[test] -fn escape_pressed_twice_resets_a_rotated_view() { +fn one_escape_resets_a_rotated_view() { let mut w = Window::new(); w.escape(0.0); - assert_eq!(w.rotation(), 30.0, "one Escape"); + assert_eq!(w.rotation(), 0.0, "one Escape with nothing selected"); w.escape(0.5); - assert_eq!(w.rotation(), 0.0, "a second Escape 0.5 s later"); - w.escape(0.5); - assert_eq!(w.rotation(), 0.0, "a third Escape leaves the view at 0°"); + assert_eq!(w.rotation(), 0.0, "a second Escape leaves the view at 0°"); let mut w = Window::new(); - let empty = w.app.canvas_rect.unwrap().left_top() + egui::vec2(20.0, 20.0); + w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + w.after(0.0, vec![]); + assert_eq!(selected(&w.app), 1); w.escape(0.0); - w.click(0.2, egui::PointerButton::Primary, empty); - w.escape(0.2); - assert_eq!(w.rotation(), 30.0, "a click between the two"); - let mut w = Window::new(); - w.escape(0.0); - w.after(0.2, key(egui::Key::ArrowRight, false)); - w.escape(0.2); - assert_eq!(w.rotation(), 30.0, "an arrow key between the two"); - let mut w = Window::new(); - w.escape(0.0); - w.after(0.2, key(egui::Key::ShiftLeft, false)); - w.escape(0.2); - assert_eq!(w.rotation(), 0.0, "a Shift press between the two"); + assert_eq!((w.rotation(), selected(&w.app)), (0.0, 1), "one Escape with a selected object, which stays selected"); let mut w = Window::new(); - w.escape(0.0); - w.escape(2.0); - assert_eq!(w.rotation(), 30.0, "two Escapes 2 s apart"); - w.escape(0.5); - assert_eq!(w.rotation(), 0.0, "the later of them starts a pair"); + let shifted = [true, false].map(|pressed| egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::SHIFT, + }); + w.after(0.0, shifted.to_vec()); + assert_eq!(w.rotation(), 0.0, "Shift+Escape"); - // A held Escape repeats: the repeats are not a second press. let mut w = Window::new(); w.after(0.0, vec![key(egui::Key::Escape, false)[0].clone()]); + assert_eq!(w.rotation(), 0.0, "the press of a held Escape"); + w.app.run("view.rotate", json!({"angle": 30})).unwrap(); for _ in 0..5 { w.after(0.1, vec![key(egui::Key::Escape, true)[0].clone()]); } w.after(0.1, vec![key(egui::Key::Escape, false)[1].clone()]); - assert_eq!(w.rotation(), 30.0, "Escape held down"); + assert_eq!(w.rotation(), 30.0, "its repeats"); + + let mut w = Window::new(); + w.app.select_tool("rotateView"); + w.escape(0.0); + assert_eq!(w.rotation(), 0.0, "one Escape with the Rotate View tool"); + + // A hover highlight that the Escape clears does not count as something it ends. + let mut w = Window::new(); + w.app.run("shape.rectangle", json!({"x": 150, "y": 100, "width": 120, "height": 80})).unwrap(); + // The new object is the selection, to be put on the path under the pointer. + w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + w.app.select_tool("objectsOnPath"); + let view = w.app.view_info(); + let over = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Move, 150.0, 140.0); + w.app.session.pointer(&over, view).unwrap(); + assert!(!w.app.session.overlays(view).is_empty(), "Objects on Path highlights the path under the pointer"); + w.escape(0.0); + assert_eq!(w.rotation(), 0.0, "one Escape with Objects on Path hovering"); } -/// The first Escape of a pair may end something, such as isolation mode. With the Rotate View -/// tool, one Escape resets the view. +/// An Escape that ends something, such as isolation mode, leaves the view as it is. The next +/// Escape resets it, however long after it comes and whatever is clicked or pressed in between. #[test] -fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view() { +fn escape_after_ending_isolation_resets_the_view() { + let isolated_window = || { + let mut w = Window::new(); + w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + w.app.run("shape.rectangle", json!({"x": 80, "y": 20, "width": 40, "height": 40})).unwrap(); + w.app.run("select.all", json!({})).unwrap(); + w.app.run("object.group", json!({})).unwrap(); + menus::invoke(&mut w.app, "object.isolate", json!({})); + w.after(0.0, vec![]); + w + }; + let isolated = |w: &Window| w.app.session.active().unwrap().isolation.is_some(); + let mut w = isolated_window(); + assert!(isolated(&w)); + w.escape(0.0); + assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the first Escape leaves isolation mode"); + w.escape(5.0); + assert_eq!(w.rotation(), 0.0, "the second resets the view 5 s later"); + + let mut w = isolated_window(); + let empty = w.app.canvas_rect.unwrap().left_top() + egui::vec2(20.0, 20.0); + w.escape(0.0); + assert_eq!((isolated(&w), w.rotation()), (false, 30.0)); + w.click(2.0, egui::PointerButton::Primary, empty); + w.after(0.5, key(egui::Key::ArrowRight, false)); + w.escape(3.0); + assert_eq!(w.rotation(), 0.0, "after a click and an arrow key between the two"); +} + +/// An Escape that ends the path the Pen, the Smart Pen or the Curvature tool is drawing leaves the +/// view as it is, with the rubber band shown or not; the next Escape resets it. +#[test] +fn escape_that_ends_a_path_being_drawn_keeps_the_rotation() { + for (tool, rubber_band) in [("pen", true), ("pen", false), ("smartPen", true), ("smartPen", false), ("curvature", true)] { + let mut w = Window::new(); + w.app.session.prefs.pen_rubber_band = rubber_band; + w.app.select_tool(tool); + let c = w.app.canvas_rect.unwrap().center(); + w.click(0.0, egui::PointerButton::Primary, c); + w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0)); + w.after(0.1, vec![egui::Event::PointerMoved(c + egui::vec2(60.0, 80.0))]); + assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: a path is being drawn"); + w.escape(0.2); + assert_eq!(w.rotation(), 30.0, "{tool}, rubber band {rubber_band}: the Escape that ends the path"); + assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: the path is ended"); + w.escape(3.0); + assert_eq!(w.rotation(), 0.0, "{tool}, rubber band {rubber_band}: the next Escape"); + } +} + +/// In isolation mode, an Escape that ends the Pen's path ends only the path: the next Escape leaves +/// isolation mode, and the one after it resets the view. +#[test] +fn escape_ends_one_thing_at_a_time() { let mut w = Window::new(); w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); w.app.run("shape.rectangle", json!({"x": 80, "y": 20, "width": 40, "height": 40})).unwrap(); w.app.run("select.all", json!({})).unwrap(); w.app.run("object.group", json!({})).unwrap(); menus::invoke(&mut w.app, "object.isolate", json!({})); + w.after(0.0, vec![]); let isolated = |w: &Window| w.app.session.active().unwrap().isolation.is_some(); assert!(isolated(&w)); - w.escape(0.0); - assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the first Escape leaves isolation mode"); - w.escape(0.5); - assert_eq!(w.rotation(), 0.0, "the second resets the view"); - - let mut w = Window::new(); - w.app.select_tool("rotateView"); - w.escape(0.0); - assert_eq!(w.rotation(), 0.0, "one Escape with the Rotate View tool"); + w.app.select_tool("pen"); + let c = w.app.canvas_rect.unwrap().center(); + w.click(0.0, egui::PointerButton::Primary, c); + w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0)); + assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "a path is being drawn"); + w.escape(0.2); + assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "the first Escape ends the path"); + assert_eq!((isolated(&w), w.rotation()), (true, 30.0), "and only the path"); + w.escape(0.2); + assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the second leaves isolation mode"); + w.escape(0.2); + assert_eq!(w.rotation(), 0.0, "the third resets the view"); } -/// An Escape that closes a popup (the status bar's angle menu, the canvas context menu) does not -/// count, so the Escape after it leaves the view as it is. An Escape while the ruler origin is -/// dragged does not reset the view either, but it counts as the first Escape of a pair. +/// The Escape that leaves the Artboard tool leaves the view as it is; the next resets it. #[test] -fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() { +fn escape_after_leaving_the_artboard_tool_resets_the_view() { + let mut w = Window::new(); + w.app.select_tool("artboard"); + w.escape(0.0); + assert_eq!((w.app.session.tool_id(), w.rotation()), ("selection", 30.0), "the first Escape leaves the Artboard tool"); + w.escape(5.0); + assert_eq!(w.rotation(), 0.0, "the next resets the view"); +} + +/// An Escape that a dialog or a full screen mode takes leaves the view as it is; the next resets it. +#[test] +fn escape_in_a_dialog_or_a_full_screen_mode_keeps_the_rotation() { + let mut w = Window::new(); + menus::invoke(&mut w.app, "view.rotate", json!({})); + w.after(0.0, vec![]); + assert!(w.app.ui.dialog.is_some(), "Rotate View asks for an angle"); + w.escape(0.0); + assert_eq!((w.app.ui.dialog.is_none(), w.rotation()), (true, 30.0), "Escape closes the dialog"); + w.escape(0.5); + assert_eq!(w.rotation(), 0.0, "the next Escape"); + + for mode in [1, 2] { + let mut w = Window::new(); + w.app.ui.screen_mode = mode; + w.after(0.0, vec![]); + w.escape(0.0); + assert_eq!((w.app.ui.screen_mode, w.rotation()), (0, 30.0), "Escape leaves screen mode {mode}"); + w.escape(0.5); + assert_eq!(w.rotation(), 0.0, "the next Escape after screen mode {mode}"); + + // Shift+Escape does not leave a full screen mode, so it resets the view there. + let mut w = Window::new(); + w.app.ui.screen_mode = mode; + w.after(0.0, vec![]); + let shifted = [true, false].map(|pressed| egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::SHIFT, + }); + w.after(0.0, shifted.to_vec()); + assert_eq!((w.app.ui.screen_mode, w.rotation()), (mode, 0.0), "Shift+Escape in screen mode {mode}"); + } + + let mut w = Window::new(); + w.app.run("view.presentation", json!({})).unwrap(); + w.after(0.0, vec![]); + assert_eq!(w.app.ui.screen_mode, 3, "Presentation Mode"); + w.escape(0.0); + assert_eq!((w.app.ui.screen_mode != 3, w.rotation()), (true, 30.0), "Escape leaves Presentation Mode"); + w.escape(0.5); + assert_eq!(w.rotation(), 0.0, "the next Escape after Presentation Mode"); +} + +/// An Escape while About or an Appearance row's popup is open, or while a pointer button is held +/// outside the canvas (a scroll bar, a tab or a panel being dragged) or let go in the same frame, +/// leaves the view as it is. +#[test] +fn escape_with_a_window_open_or_a_button_held_keeps_the_rotation() { + let mut w = Window::new(); + w.app.ui.about = true; + w.after(0.0, vec![]); + w.escape(0.0); + assert_eq!(w.rotation(), 30.0, "an Escape with About open"); + + // An Appearance row's popup (here, the Stroke panel's) closes on Escape as the frame is drawn. + let mut w = Window::new(); + w.app.ui.transient_panel = Some("stroke".into()); + w.escape(0.0); + assert_eq!(w.rotation(), 30.0, "an Escape with an Appearance row's popup open"); + + let mut w = Window::new(); + let bar = w.app.canvas_rect.unwrap().right_center() + egui::vec2(4.0, 0.0); + let button = |pressed| egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + w.after(0.0, vec![egui::Event::PointerMoved(bar), button(true)]); + w.escape(0.1); + assert_eq!(w.rotation(), 30.0, "an Escape with the button held"); + let mut release = vec![button(false)]; + release.extend(key(egui::Key::Escape, false)); + w.after(0.1, release); + assert_eq!(w.rotation(), 30.0, "an Escape in the frame the button is let go"); + w.escape(0.1); + assert_eq!(w.rotation(), 0.0, "an Escape after the release"); +} + +/// An Escape in a text field ends the editing and leaves the view as it is. +#[test] +fn escape_in_a_text_field_keeps_the_rotation() { + let mut w = Window::new(); + w.ctx.enable_accesskit(); + let out = w.after(0.0, vec![]); + let tree = out.platform_output.accesskit_update.expect("the window's accessibility tree"); + let field = tree.nodes.iter().find(|(_, node)| node.role() == egui::accesskit::Role::TextInput).map(|(id, _)| *id).expect("a text field"); + let field = w + .ctx + .viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter()).find(|r| r.id.accesskit_id() == field).copied()) + .expect("the text field's widget"); + w.ctx.memory_mut(|m| m.request_focus(field.id)); + w.after(0.0, vec![]); + assert!(w.ctx.egui_wants_keyboard_input(), "the field has the keyboard"); + w.escape(0.0); + assert_eq!(w.rotation(), 30.0, "the Escape in the field"); +} + +/// An Escape that closes a popup (the status bar's angle menu, the canvas context menu) or ends a +/// drag of the ruler origin leaves the view as it is; the next Escape resets it. +#[test] +fn escape_that_closes_a_popup_or_ends_a_ruler_origin_drag_keeps_the_rotation() { let mut w = Window::new(); // Find the actual combo by its accessible name, which is present in both build profiles; // `Id`'s debug source (including "rot-combo") only exists with debug assertions enabled. @@ -354,8 +533,9 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation assert!(craft_ui::ComboBox::is_open(&w.ctx, combo.id), "the angle menu is open"); w.escape(0.2); assert!(!egui::Popup::is_any_open(&w.ctx), "Escape closes it"); + assert_eq!(w.rotation(), 30.0, "the Escape that closes the angle menu"); w.escape(0.2); - assert_eq!(w.rotation(), 30.0, "an Escape after the angle menu closed"); + assert_eq!(w.rotation(), 0.0, "the next Escape"); let mut w = Window::new(); w.click(0.0, egui::PointerButton::Secondary, w.app.canvas_rect.unwrap().center()); @@ -363,8 +543,9 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation assert!(egui::Popup::is_any_open(&w.ctx), "a right click opens the context menu"); w.escape(0.2); assert!(!egui::Popup::is_any_open(&w.ctx), "Escape closes it"); + assert_eq!(w.rotation(), 30.0, "the Escape that closes the context menu"); w.escape(0.2); - assert_eq!(w.rotation(), 30.0, "an Escape after the context menu closed"); + assert_eq!(w.rotation(), 0.0, "the next Escape"); // With the Rotate View tool, the Escape that closes the menu leaves the view as it is too. let mut w = Window::new(); w.app.select_tool("rotateView"); @@ -391,5 +572,5 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation assert_eq!(w.rotation(), 30.0, "two Escapes while the ruler origin is dragged"); w.after(0.1, vec![button(to, false)]); w.escape(0.2); - assert_eq!(w.rotation(), 0.0, "an Escape after the drag ends pairs with the last one"); + assert_eq!(w.rotation(), 0.0, "an Escape after the drag ends"); } diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 56bcca519..3154ccb6d 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -677,7 +677,7 @@ fn a_tool_flyout_takes_escape_before_full_screen() { } #[test] -fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() { +fn escape_exits_full_screen_then_resets_the_rotation_with_selection_tool() { for mode in [1, 2] { let ctx = egui::Context::default(); let mut app = in_screen_mode(&ctx, mode); @@ -686,7 +686,7 @@ fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() { assert_eq!(app.ui.screen_mode, 0, "first Escape in mode {mode}"); assert_eq!(app.view().unwrap().rotation, 30.0); frame(&mut app, &ctx, escape()); - assert_eq!(app.view().unwrap().rotation, 0.0, "second Escape after mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 0.0, "the next Escape after mode {mode}"); } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c65afc74..0713db3bc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1031,7 +1031,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Rotate View turns the view to the status bar's preset angles (`view.rotate {angle}`, which takes any angle). View › Rotate View to Selection turns the view to the angle of the selected objects' bounding box, so the box is square to the window (`view.rotateToSelection`). When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then. While the desktop app's render worker renders the frame for a new view, the parts of the canvas the last frame does not cover after a pan, a zoom-out or a turn show the art from a frame that a second worker renders at a quarter of the resolution, covering twice the canvas's width and height. That frame draws images only from pixels already decoded (an image not yet decoded shows as a light gray box), and it renders large raster effects at the lower resolution a drag uses. Until it is ready, those parts show the pasteboard and the artboards. They used to show only the pasteboard and the artboards until the frame for the new view was ready. - With the view rotated, a second Escape less than a second after the first resets the rotation, as Reset Rotate View does. The first Escape may end something else, such as isolation mode or a path being drawn. The second resets the view only when it has nothing else to end, and an Escape that closes a menu does not count. After a click, or a press of another key that is not a modifier (Shift, Control, Alt/Option or Command), the next Escape counts as the first. With the Rotate View tool chosen, one Escape resets the view. + With the view rotated, an Escape that has nothing else to end resets the rotation, as Reset Rotate View does, with or without a selection, which stays selected. An Escape that ends something else ends only that, and the next Escape resets the view: to reset the view while another tool or feature is in use, Escape is pressed twice ([Rotate canvas view](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/rotate-canvas-view.html)). Under VectorCraft's own rule, the things an Escape ends first are isolation mode, pattern editing, Global Edit, a tool flyout, a full screen mode, Presentation Mode, a path being drawn with the Pen, Smart Pen or Curvature tool, the Blend tool's first pick, the Artboard tool, a tool's drag, a menu, a dialog, a text field and a drag of the ruler origin, and the second press may come any time later, after clicks or other keys. With the Rotate View tool, the view resets before a full screen mode ends; with another tool, Escape leaves a full screen mode first, and Shift+Escape, which does not leave it, resets the view. An Escape does not reset the view while About, a progress window or an Appearance row's popup is open, while a pointer button is held, or in the frame a button is let go. It used to reset the view only as the second of two Escape presses less than a second apart. A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default). On a touch screen a second finger cancels the tool press the first finger began, in the frame it lands or later: the Pencil's stroke or a Selection move is rolled back with no undo entry, and a finger that lands while another is down presses nothing, so a pinch or two-finger pan only zooms and pans (#4198; canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`). A tool press dropped by a second finger, a long touch or a canceled touch cancels the tool's drag as Escape does: the tool gets back the options it had when the press began (the Polygon tool's sides, set with ↑ and ↓) and they are not stored, a press held still opens no size dialog, a Selection or Artboard tool marquee selects nothing, and the Pen goes on drawing its path without the dropped anchor (canvas tests `a_canceled_touch_drops_the_drag_without_keeping_its_options` and `long_touch_drops_a_tool_press_without_committing_art`; engine tests in `tests::toolsettings`). From 27616b1d7b4072f00acfdd8492d9b28528d0e61b Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Tue, 10 Nov 2026 12:09:02 +0500 Subject: [PATCH 40/48] =?UTF-8?q?#4936:=20Spelling=20=E2=80=BA=20Auto=20Sp?= =?UTF-8?q?ell=20Check=20in=20the=20context=20menu=20of=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A right-click on type has a Spelling submenu with Auto Spell Check, as the public Check and correct spelling page describes. A submenu that holds only toggles shows their check marks, even in the context menu. --- crates/ui-egui/src/menus/bar.rs | 9 +++++- crates/ui-egui/src/menus/context.rs | 7 ++++- crates/ui-egui/src/tests/contextmenu.rs | 40 +++++++++++++++++++++++++ docs/gaps.md | 5 ++-- docs/mcp.md | 2 +- docs/roadmap.md | 3 +- docs/target-app-parity.md | 7 +++-- docs/type-parity.md | 5 ++-- 8 files changed, 68 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index c57a21637..cbc1536fc 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -127,7 +127,7 @@ pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Ite ui.menu_button(label, |ui| { widgets::menu_scroll(ui, |ui| { ui.set_min_width(200.0); - render_items(app, ui, children, checks, clicked); + render_items(app, ui, children, checks || only_toggles(app, children), clicked); }); }); } @@ -136,6 +136,13 @@ pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Ite } } +/// Whether every item of a submenu can be on or off: a menu drawn without check marks (the canvas +/// context menu) still shows them there, since its label alone can't say whether it is on. +fn only_toggles(app: &VectorcraftApp, items: &[Item]) -> bool { + let mut rows = items.iter().filter_map(|it| entry(app, it)).filter(|e| !matches!(e, Entry::Sep)).peekable(); + rows.peek().is_some() && rows.all(|e| matches!(e, Entry::Item(r) if r.checked.is_some())) +} + /// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut, /// and Type › Font's samples. fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) { diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index 387cd099c..abf43a358 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -92,6 +92,10 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> { // The paragraphs of the selected type, or those the Type tool's selection touches. if crate::panels::character::text_style(app).is_some() { v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")])); + // A right-click on type turns Auto Spell Check on or off, as Edit › Spelling does, per + // the public [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html) + // page. Its submenu holds only toggles, so it shows the check mark (`render_items`). + v.push(sub("Spelling", vec![c("Auto Spell Check", "edit.autoSpellCheck")])); } v.extend([c("Make Guides", "view.guides.make"), Sep, sub("Transform", transform_items())]); if intertwine.is_none() { @@ -265,7 +269,8 @@ fn prune(app: &VectorcraftApp, items: Vec<Item>) -> Vec<Item> { pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { widgets::menu_scroll(ui, |ui| { ui.set_min_width(200.0); - // Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter. + // Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter, + // except in a submenu of toggles alone (Spelling › Auto Spell Check). render_items(app, ui, &context_items(app), false, clicked); }); } diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index 26e04961b..b443d8458 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -291,6 +291,46 @@ fn selected_type_has_apply_numbers() { assert_eq!(text.para_at(0).list.and_then(|l| l.number), Some(vectorcraft_doc::text::NumberStyle::Arabic)); } +/// A right-click on type has Spelling › Auto Spell Check, which turns it on or off as Edit › +/// Spelling does (helpx: Check and correct spelling, "Turn on Auto Spell Check"). The context menu +/// draws no check marks, but this submenu holds only the toggle, so it shows whether it is on; +/// other submenus stay without the room for one. Other art has no Spelling submenu. +#[test] +fn right_click_on_type_turns_auto_spell_check_on_and_off() { + let mut app = app(); + let r = rect(&mut app, 200.0, 150.0); + let t = NodeId(app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = screen(&app, 250.0, 200.0); + assert!(!has(&click(&mut app, &ctx, p, PointerButton::Secondary), "Spelling"), "a rectangle has no Spelling submenu"); + assert_eq!(app.session.active().unwrap().selection.objects, [r]); + let away = screen(&app, 390.0, 290.0); + click(&mut app, &ctx, away, PointerButton::Primary); + // Right-click the type, open Spelling: Auto Spell Check, not checked. + let open_spelling = |app: &mut VectorcraftApp| { + let p = word_at(app, t, 0, 5); + let texts = click(app, &ctx, p, PointerButton::Secondary); + assert_eq!(app.session.active().unwrap().selection.objects, [t], "the type right-clicked is selected"); + frame(app, &ctx, vec![Event::PointerMoved(at(&texts, "Spelling"))]); + frame(app, &ctx, vec![]) + }; + let texts = open_spelling(&mut app); + assert!(texts.iter().any(|(s, _)| s == " Auto Spell Check"), "unchecked, with the room for a check mark: {texts:?}"); + assert!(texts.iter().any(|(s, _)| s == "Transform"), "other items have no gutter: {texts:?}"); + // Agents see it under the Spelling path. + let e = menus::context_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("edit.autoSpellCheck")).expect("Auto Spell Check"); + assert_eq!((e.path.as_slice(), e.label.as_str()), (&["Spelling".to_string()][..], "Auto Spell Check")); + let texts = click(&mut app, &ctx, at(&texts, " Auto Spell Check"), PointerButton::Primary); + assert!(app.ui.view.auto_spell_check, "turned on"); + assert!(!has(&texts, "Spelling"), "the menu closed: {texts:?}"); + let texts = open_spelling(&mut app); + assert!(texts.iter().any(|(s, _)| s == "✓ Auto Spell Check"), "checked: {texts:?}"); + click(&mut app, &ctx, at(&texts, "✓ Auto Spell Check"), PointerButton::Primary); + assert!(!app.ui.view.auto_spell_check, "turned off"); +} + /// The members of the overlap area of Intertwine group `g` at (x, y), in the order they are drawn /// there, bottom first. fn intertwine_order(app: &VectorcraftApp, g: NodeId, x: f64, y: f64) -> Vec<NodeId> { diff --git a/docs/gaps.md b/docs/gaps.md index cd3a77db6..bf0dbdce7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,10 +1,11 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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; estimate 1–2 h → 0; 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: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; 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, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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; estimate 1–2 h → 0; 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: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; 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, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence **Feature evidence last updated:** 2026-11-02. +| 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -450,7 +451,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Missing:** CJK vertical composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and - `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain), + `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Check Spelling and Edit Custom Dictionary dialogs remain), Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). - **Impact:** professional typography and Japanese/Chinese publishing. diff --git a/docs/mcp.md b/docs/mcp.md index 4bf2e6c2b..267ca4cd2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6028,7 +6028,7 @@ lines up to date as the type, the custom dictionary and the Ignore All words cha A right-click on an underlined word puts at the top of the canvas context menu the words `text.spelling.suggest` gives for it (each a `text.editRange {id, start, end, insert}` item labelled with its word), or No Suggestions, then Ignore All (`text.spelling.ignoreAll {word}`) and Add to Dictionary (`text.spelling.addWord {word}`); the context menu's -entries list them with their params. +entries list them with their params. A right-click on type also has Spelling › Auto Spell Check (`edit.autoSpellCheck`). ## Justification options diff --git a/docs/roadmap.md b/docs/roadmap.md index a0361d72a..6e7c8f56a 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-10 · **Change:** 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-10 · **Change:** 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) > Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). @@ -42,6 +42,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 826108b5f..0c4ff51d7 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-10 · **Change:** 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-10 · **Change:** 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) ## Feature submission evidence @@ -41,6 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours 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. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | @@ -1909,7 +1910,9 @@ The custom dictionary and Ignore All followed (2026-11-09), as the same page des Auto Spell Check followed (2026-11-09): the same page has Edit › Spelling › Auto Spell Check underline potential spelling errors with a red line as you type, and says it isn't available for text with effects applied (#4936). `edit.autoSpellCheck` turns it on and off (off at first, saved with the view settings); the canvas draws a red wavy line under each word `text.spelling.check` would list in the visible type, leaves out type with an effect shown on it or on its fills and strokes, and checks a type object again when it is edited, when the custom dictionary changes and when Ignore All adds a word (`ui-egui/src/canvas/tests.rs`, `auto_spell_check_*`; `tests/spelling.rs`). The dialogs remain. -The right-click followed (2026-11-10), as the same page's "Correct spelling errors during Auto Spell Check" describes it: right-click a word with a red underline, then choose a suggested word, Ignore All to skip it in the current document, or Add to Dictionary (#4936). The canvas context menu lists the words `text.spelling.suggest` gives for it in its run's language, each replacing the word with `text.editRange` as one undo step, or No Suggestions; then Ignore All (`text.spelling.ignoreAll`) and Add to Dictionary (`text.spelling.addWord`). It lists nothing for a word that is right, with Auto Spell Check off, or once the word under the right-click has changed (`ui-egui/src/tests/contextmenu.rs`). The dialogs and the context menu's Spelling › Auto Spell Check remain. +The right-click followed (2026-11-10), as the same page's "Correct spelling errors during Auto Spell Check" describes it: right-click a word with a red underline, then choose a suggested word, Ignore All to skip it in the current document, or Add to Dictionary (#4936). The canvas context menu lists the words `text.spelling.suggest` gives for it in its run's language, each replacing the word with `text.editRange` as one undo step, or No Suggestions; then Ignore All (`text.spelling.ignoreAll`) and Add to Dictionary (`text.spelling.addWord`). It lists nothing for a word that is right, with Auto Spell Check off, or once the word under the right-click has changed (`ui-egui/src/tests/contextmenu.rs`). The dialogs remain. + +The page's other way to turn Auto Spell Check on followed (2026-11-10): right-click the text and choose Spelling › Auto Spell Check (#4936). A right-click on type (which selects it) has a Spelling submenu with Auto Spell Check, which runs `edit.autoSpellCheck` as Edit › Spelling does. The context menu draws no check marks, its toggles saying what they do (Show Rulers, Hide Guides), but a submenu that holds only toggles shows them, so Auto Spell Check is checked while it is on; other art has no Spelling submenu (`right_click_on_type_turns_auto_spell_check_on_and_off`). ## Control bar: Paragraph section while type is selected (2026-11-06) diff --git a/docs/type-parity.md b/docs/type-parity.md index 48eefbc70..8f3c8e7c2 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -47,7 +47,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | -| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary. The Check Spelling and Edit Custom Dictionary dialogs and the context menu's Spelling › Auto Spell Check remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click). | +| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. The Check Spelling and Edit Custom Dictionary dialogs remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click). | | Smart Punctuation, Change Case, Find & Replace | done | | | Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on type has Spelling › Auto Spell Check, checked while it is on; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-09 | minor | Auto Spell Check (#4936): Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, without type with effects; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain. Scores and estimates unchanged. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | From 19ce2b25472248a44cc701777d6c2cb0a6399fad Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 23:09:51 -0800 Subject: [PATCH 41/48] Scripts: a command's savepoint copies no undo steps but the newest Each command a script runs keeps a savepoint of the state it may need to put back if it fails. The savepoint cloned every open document's whole undo history, so a script that made N edits did O(N^2) work. - The savepoint now clones each document's state without its history and keeps a mark: the newest undo and redo steps and the serials of the steps under them. Until the command ends, the history trims no step, and a new step sets the redo steps aside instead of dropping them. A failed command truncates both stacks back to the mark. - file.close, file.closeAll, document.close and file.revert still keep a whole copy, since they remove the history or the document. - When a failed command removed or changed a step under the newest one, the history cannot be put back exactly, and the run stops with an error; its rollback restores the documents as they were before the run. - run_guarded no longer copies the histories during a script's run, and a committed run moves the histories from before it back instead of copying them. - Symbol editing mode shares its saved redo steps between copies of a document's state. - A script cannot run edit.clearHistory. A run keeps each document's undo steps from before it, so the command reported a clear that the commit then undid. --- .../engine/src/cmd/distortcmds/perspective.rs | 1 + crates/engine/src/cmd/symboledit.rs | 7 +- crates/engine/src/doc_state.rs | 2 +- crates/engine/src/history.rs | 122 +++++++++++++++++- crates/engine/src/script/mod.rs | 97 +++++++++++--- crates/engine/src/session/execute.rs | 13 +- crates/engine/src/tests/script_run.rs | 93 +++++++++++++ crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 5 +- 30 files changed, 336 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index a561e5099..8c3525449 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -635,6 +635,7 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { } }; st.change_unsaved(clear); + st.history.rewrites += 1; for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { let saved = Arc::ptr_eq(&e.doc, &st.saved_doc); clear(&mut e.doc); diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index a3745975b..22cfc74e9 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -140,7 +140,7 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> { } s.end_text_edit(); let st = s.doc()?; - let back = Return { redo: st.history.redo.clone(), before: Some(st.doc.clone()) }; + let back = Return { redo: Arc::new(st.history.redo.clone()), before: Some(st.doc.clone()) }; let (isolation, active_layer) = (st.isolation, st.active_layer); let layer = s.edit(ENTER, |d, sel| { let layer = begin(d, sel, &name, xf, instance)?; @@ -229,7 +229,7 @@ pub(crate) fn leave(s: &mut Session, _: &Value) -> Result<Value> { } st.selection = Arc::unwrap_or_clone(entry.selection); st.selection.prune(&st.doc); - st.history.redo = redo; + st.history.redo = Arc::unwrap_or_clone(redo); st.revision += 1; } else { s.edit("Exit Symbol Editing Mode", |d, sel| { @@ -257,9 +257,10 @@ pub(crate) fn finish(d: &mut Document, sel: &mut Selection) { /// The redo steps entering symbol editing mode cleared, which leaving without an edit gives back, /// and the document entering started from, which tells the entering step apart from any other. +/// Copies of the document's state share the redo steps. #[derive(Clone, Debug, Default)] pub(crate) struct Return { - redo: Vec<crate::HistoryEntry>, + redo: Arc<Vec<crate::HistoryEntry>>, before: Option<Arc<Document>>, } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 736b873d0..d0e486240 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -446,7 +446,7 @@ impl DocState { } let e = HistoryEntry::kept(label, doc, selection, &self.doc); self.history.undo.push(e); - self.history.redo.clear(); + self.history.clear_redo(); self.history.trim(); } /// End the interaction in progress, undoing what it changed. diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 8df6060e0..8dced7fb9 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -1,7 +1,13 @@ use super::{Arc, Document, Selection}; +#[cfg(test)] +thread_local! { + /// Tests: how many undo steps were copied on this thread. + pub(crate) static ENTRY_CLONES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; +} + /// One undo step. -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct HistoryEntry { pub label: String, /// The document as it was. It keeps no lookup cache ([`Document::drop_lookup_cache`]): a @@ -27,6 +33,25 @@ pub struct HistoryEntry { color_mode_bytes: usize, } +impl Clone for HistoryEntry { + fn clone(&self) -> Self { + #[cfg(test)] + ENTRY_CLONES.with(|n| n.set(n.get() + 1)); + HistoryEntry { + label: self.label.clone(), + doc: self.doc.clone(), + selection: self.selection.clone(), + bytes: self.bytes, + next_bytes: self.next_bytes, + next: self.next, + named: self.named.clone(), + serial: self.serial, + color_mode_paints: self.color_mode_paints.clone(), + color_mode_bytes: self.color_mode_bytes, + } + } +} + /// The next [`HistoryEntry::serial`]: 1 for the first step made, so 0 never names a step. fn next_serial() -> u64 { static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -148,11 +173,52 @@ pub struct History { /// About the most memory the steps may keep ([`HistoryEntry::bytes`]): past it the oldest /// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps. pub byte_budget: usize, + /// Counts the changes made in place to steps below the newest ([`History::put_back`]). + pub(crate) rewrites: u64, + /// Between [`History::mark`] and [`History::release`] or [`History::put_back`]. + pub(crate) held: bool, + /// The redo steps a new step cleared while held ([`History::clear_redo`]). + pub(crate) set_aside: Option<Vec<HistoryEntry>>, } impl Default for History { fn default() -> Self { - History { undo: vec![], redo: vec![], limit: 0, byte_budget: default_history_budget() } + History { undo: vec![], redo: vec![], limit: 0, byte_budget: default_history_budget(), rewrites: 0, held: false, set_aside: None } + } +} + +/// What [`History::put_back`] needs to give a history back as it was when [`History::mark`] took +/// it: the newest undo and redo steps, and where each stack stood under them. It holds no other +/// step, so taking one costs the same however many the history holds. +pub(crate) struct HistoryMark { + undo: StackMark, + redo: StackMark, + limit: usize, + byte_budget: usize, + rewrites: u64, +} + +/// One stack of a [`HistoryMark`]. +struct StackMark { + len: usize, + /// [`HistoryEntry::serial`] of the step under the newest one, 0 for none. + below: u64, + newest: Option<HistoryEntry>, +} + +impl StackMark { + fn of(steps: &[HistoryEntry]) -> Self { + let len = steps.len(); + StackMark { len, below: len.checked_sub(2).and_then(|i| steps.get(i)).map_or(0, |e| e.serial), newest: steps.last().cloned() } + } + + /// Puts `steps` back as they were. → `false` when a step under the newest one was removed since. + fn put_back(self, steps: &mut Vec<HistoryEntry>) -> bool { + let kept = self.len.saturating_sub(1); + let intact = steps.len() >= kept && kept.checked_sub(1).is_none_or(|i| steps.get(i).is_some_and(|e| e.serial == self.below)); + steps.truncate(kept); + steps.extend(self.newest); + intact } } @@ -208,6 +274,58 @@ impl History { self.redo.drain(..n); } + /// A [`HistoryMark`] of the history now. Until [`History::release`] or [`History::put_back`], + /// [`History::trim`] lets no step go, so the steps under the newest one stay where they are, and + /// [`History::clear_redo`] sets the redo steps aside. + pub(crate) fn mark(&mut self) -> HistoryMark { + let mark = HistoryMark { + undo: StackMark::of(&self.undo), + redo: StackMark::of(&self.redo), + limit: self.limit, + byte_budget: self.byte_budget, + rewrites: self.rewrites, + }; + self.limit = usize::MAX; + self.byte_budget = usize::MAX; + self.held = true; + self.set_aside = None; + mark + } + + /// Removes the redo steps, as a new step does. While held ([`History::mark`]) the first call + /// keeps them for [`History::put_back`]. + pub(crate) fn clear_redo(&mut self) { + if self.held && self.set_aside.is_none() { + self.set_aside = Some(std::mem::take(&mut self.redo)); + } else { + self.redo.clear(); + } + } + + /// Keeps the history as it is now: the limits `mark` held back apply again. + pub(crate) fn release(&mut self, mark: &HistoryMark) { + self.held = false; + self.set_aside = None; + self.limit = mark.limit; + self.byte_budget = mark.byte_budget; + self.trim(); + } + + /// Gives the history back as it was at `mark`. → `false` when that is not possible: an undo or + /// redo step under the newest one was removed or changed in place since. + pub(crate) fn put_back(&mut self, mark: HistoryMark) -> bool { + if let Some(redo) = self.set_aside.take() { + self.redo = redo; + } + let undo = mark.undo.put_back(&mut self.undo); + let redo = mark.redo.put_back(&mut self.redo); + let intact = self.rewrites == mark.rewrites && undo && redo; + self.held = false; + self.limit = mark.limit; + self.byte_budget = mark.byte_budget; + intact + } + /// Estimate the newest undo step again, its next state now being `now` (the steps after it were /// merged into it, or an undo group extended it). It takes a new number ([`History::step`]). pub(crate) fn recost_last(&mut self, now: &Document) { diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 632d45de0..35e93c1ad 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -78,7 +78,8 @@ pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args" pub const STEP_LABEL: &str = "Run Script"; /// `id`, as `vc.run` is given it, is one a script may not run: a command that would leave the run's -/// transaction or change something outside the documents that a rollback could not put back. +/// transaction, change something outside the documents that a rollback could not put back, or clear +/// the undo steps from before the run, which the run keeps. pub fn refused_to_scripts(id: &str) -> bool { const PREFIXES: &[&str] = &[ "script.", @@ -107,6 +108,7 @@ pub fn refused_to_scripts(id: &str) -> bool { "view.proofColors", "view.overprintPreview", "view.separationsPreview", + "edit.clearHistory", ]; let saved_preset = (id.contains("resets.") || id.contains(".preset.")) && !id.ends_with(".list") && !id.ends_with(".apply"); id != "script.check" && (saved_preset || PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id)) @@ -302,10 +304,23 @@ impl World { /// themselves. The clipboard can hold a great deal, so a savepoint copies it only for these. const CAN_FAIL_AFTER_COPYING: &[&str] = &["action.batch", "edit.cut"]; +/// The commands that remove a document's undo history, or the document: a savepoint keeps a whole +/// copy of the histories for these. Any other command that removes or changes a step under the +/// newest undo or redo step and then fails ends the run ([`HISTORY_LOST`]). +const DROP_HISTORY: &[&str] = &["file.close", "file.closeAll", "document.close", "file.revert"]; + +/// A document's state before a command: everything but its undo history, which `mark` gives back, +/// or `whole` for the commands in [`DROP_HISTORY`]. +struct SavedDoc { + state: DocState, + mark: crate::history::HistoryMark, + whole: Option<crate::History>, +} + /// What one command may need to put back when it fails inside a run: the state of [`World`] the /// command can reach. pub(crate) struct Savepoint { - docs: Vec<DocState>, + docs: Vec<SavedDoc>, active: Option<usize>, paint: PaintDefaults, fill_active: bool, @@ -323,9 +338,23 @@ pub(crate) struct Savepoint { } impl Savepoint { - pub(crate) fn take(s: &Session, id: &str) -> Self { + /// The state before command `id`. Until [`Savepoint::keep`] or [`Savepoint::restore`], the + /// documents' undo histories let no step go. + pub(crate) fn take(s: &mut Session, id: &str) -> Self { + let whole = DROP_HISTORY.contains(&id); + let docs = s + .docs + .iter_mut() + .map(|st| { + let history = std::mem::take(&mut st.history); + let state = st.clone(); + st.history = history; + let whole = whole.then(|| st.history.clone()); + SavedDoc { state, mark: st.history.mark(), whole } + }) + .collect(); Self { - docs: s.docs.clone(), + docs, active: s.active, paint: s.paint.clone(), fill_active: s.fill_active, @@ -343,8 +372,39 @@ impl Savepoint { } } - pub(crate) fn restore(self, s: &mut Session) { - s.docs = self.docs; + /// Keeps what the command did: the histories' limits apply again. + pub(crate) fn keep(self, s: &mut Session) { + for saved in &self.docs { + if let Some(st) = s.docs.iter_mut().find(|d| d.uid == saved.state.uid) { + st.history.release(&saved.mark); + } + } + } + + /// Puts back the state before the command. → `false` when an undo history could not be given + /// back as it was (a step under the newest one was removed or changed in place, or its + /// document was closed); the rest is put back all the same. + pub(crate) fn restore(self, s: &mut Session) -> bool { + let mut exact = true; + let mut docs = Vec::with_capacity(self.docs.len()); + for SavedDoc { mut state, mark, whole } in self.docs { + let now = s.docs.iter_mut().find(|d| d.uid == state.uid).map(|d| std::mem::take(&mut d.history)); + state.history = match (whole, now) { + (Some(history), _) => history, + (None, Some(mut history)) => { + exact &= history.put_back(mark); + history + } + (None, None) => { + exact = false; + let mut history = crate::History::default(); + history.put_back(mark); + history + } + }; + docs.push(state); + } + s.docs = docs; s.active = self.active; s.paint = self.paint; s.fill_active = self.fill_active; @@ -361,6 +421,7 @@ impl Savepoint { s.pending_paint = self.pending_paint; self.choices.put(s); s.journal.truncate(self.journal); + exact } } @@ -620,8 +681,10 @@ fn step(s: &mut Session, host: &Host, id: &str, params: &Value) -> Result<(Value Ok((v, objects, bytes, save)) } Err(e) => { - if let Some(save) = save { - save.restore(s); + if let Some(save) = save + && !save.restore(s) + { + return Err(EngineError::Internal { cmd: id.to_string(), msg: HISTORY_LOST.to_string() }); } Err(e) } @@ -660,10 +723,13 @@ fn vc_run(host: &Rc<Host>, args: Vec<Data>) -> std::result::Result<Data, HostErr Ok(data) }); // A command whose result does not reach the script is put back, like one that failed. - if crossed.is_err() - && let Some(save) = save - { - save.restore(&mut session.borrow_mut()); + if let Some(save) = save { + let mut s = session.borrow_mut(); + if crossed.is_ok() { + save.keep(&mut s); + } else if !save.restore(&mut s) { + return Err(HostError::Abort(HISTORY_LOST.to_string())); + } } crossed } @@ -983,7 +1049,7 @@ impl Session { } note_ids(self); raise_ids(self); - let start = std::mem::take(&mut job.world.docs); + let mut start = std::mem::take(&mut job.world.docs); for st in &self.docs { let changed = start.iter().find(|b| b.uid == st.uid).is_none_or(|b| !st.same_content(&b.doc)); if changed && (!crate::sanity::doc_sane(&st.doc, &st.selection) || st.doc.too_deep(None)) { @@ -995,7 +1061,7 @@ impl Session { } let mut changed_names = Vec::new(); for st in &mut self.docs { - let Some(b) = start.iter().find(|b| b.uid == st.uid) else { + let Some(b) = start.iter_mut().find(|b| b.uid == st.uid) else { // A document the script opened or made starts with no undo steps. st.history = crate::History { limit: st.history.limit, ..crate::History::default() }; st.revision += 1; @@ -1003,7 +1069,7 @@ impl Session { continue; }; let changed = !st.same_content(&b.doc); - st.history = b.history.clone(); + st.history = std::mem::take(&mut b.history); st.recovery.clone_from(&b.recovery); if changed { st.push_undo(STEP_LABEL.to_string(), b.doc.clone(), Arc::new(b.selection.clone())); @@ -1062,6 +1128,7 @@ impl Session { const OPEN_EDIT: &str = "a drag or an undo group is open; finish it before running a script"; const INVALID_RESULT: &str = "the script left a document that cannot be kept"; const JOB_GONE: &str = "the script's run is no longer available"; +const HISTORY_LOST: &str = "a failed command changed undo steps that could not be put back, so the script was stopped"; // Text a script reads from the errors it catches, or that ends its run with its name and place. const OVER_CALLS: &str = "the script made more host calls than one run may make"; const OVER_BRIDGE: &str = "the script passed more data to and from commands than one run may pass"; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 1d38548ff..7dd5d8ae6 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -310,8 +310,13 @@ impl Session { /// document goes back to how it was before the command (its art, selection, interaction and /// undo history), and the same document stays active, instead of crashing the frontend. pub(crate) fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> { - let snapshot: Vec<_> = - self.docs.iter().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone(), d.history.clone())).collect(); + // In a script's run a panic ends the run, and the run's rollback puts the histories back. + let in_run = self.script_in_run; + let snapshot: Vec<_> = self + .docs + .iter() + .map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone(), (!in_run).then(|| d.history.clone()))) + .collect(); let active = self.active().map(|d| d.uid); let r = match guard::catch_panic(|| self.run_nested(f)) { Ok(r) => r, @@ -324,7 +329,9 @@ impl Session { st.doc = doc; st.selection = selection; st.interaction = interaction; - st.history = history; + if let Some(history) = history { + st.history = history; + } cmd::globaledit::follow_history(st); st.drop_stale_variables_highlight(); st.revision += 1; diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 81b224628..5480ba499 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -370,6 +370,88 @@ fn a_command_that_can_fail_after_copying_has_the_clipboard_put_back() { assert!(s.clipboard.source_artboard.is_some(), "only those commands pay for a copy of the clipboard"); } +#[test] +fn a_commands_savepoint_copies_the_same_number_of_undo_steps_however_long_the_runs_history() { + let copied = |n: usize| { + let mut s = two_docs(); + crate::history::ENTRY_CLONES.with(|c| c.set(0)); + let r = run(&mut s, &format!("for (var i = 0; i < {n}; i++) vc.run('shape.rectangle', {{x:i,y:1,width:1,height:1}});")).unwrap(); + assert_eq!(r["ok"], true); + assert_eq!(objects(&s, 1), n + 1); + crate::history::ENTRY_CLONES.with(Cell::get) + }; + let (few, many) = (copied(20), copied(200)); + // Each command copies at most its document's newest step, whatever the history holds. + assert!(many <= 200 + few, "{few} steps copied for 20 commands, {many} for 200"); +} + +#[test] +fn undo_redo_and_a_long_redo_stack_copy_the_same_number_of_steps_per_command() { + let copied = |n: usize| { + let mut s = two_docs(); + crate::history::ENTRY_CLONES.with(|c| c.set(0)); + let source = format!( + "for (var i = 0; i < {n}; i++) vc.run('shape.rectangle', {{x:i,y:1,width:1,height:1}});\ + for (var i = 0; i < {n}; i++) vc.run('edit.undo');\ + for (var i = 0; i < {n}; i++) vc.run('select.none');\ + for (var i = 0; i < {n}; i++) vc.run('edit.redo');" + ); + let r = run(&mut s, &source).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(objects(&s, 1), n + 1); + crate::history::ENTRY_CLONES.with(Cell::get) + }; + let (few, many) = (copied(20), copied(200)); + // Ten times the commands copy about ten times the steps: each command copies its document's + // newest undo and redo steps, whatever the stacks hold. + assert!(many <= 12 * few, "{few} steps copied for 80 commands, {many} for 800"); +} + +#[test] +fn a_failed_command_gives_back_the_redo_steps_and_an_undo_it_made() { + let mut s = two_docs(); + for x in 1..=3 { + s.execute("shape.rectangle", &rect(x)).unwrap(); + } + for _ in 0..2 { + s.execute("edit.undo", &json!({})).unwrap(); + } + let before = state(&s); + let save = script::Savepoint::take(&mut s, "shape.rectangle"); + s.execute("shape.rectangle", &rect(9)).unwrap(); + assert_eq!(s.documents()[1].history.redo.len(), 0); + assert!(save.restore(&mut s), "the redo steps the new step cleared are put back"); + assert_eq!(state(&s), before); + let save = script::Savepoint::take(&mut s, "edit.undo"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(save.restore(&mut s)); + assert_eq!(state(&s), before); + for _ in 0..2 { + s.execute("edit.redo", &json!({})).unwrap(); + } + assert_eq!(objects(&s, 1), 4); +} + +#[test] +fn a_failed_command_at_the_history_limit_keeps_the_oldest_step() { + let mut s = two_docs(); + s.docs[1].history.limit = 5; + let before = state(&s); + for x in 1..=5 { + s.execute("shape.rectangle", &rect(x)).unwrap(); + } + let save = script::Savepoint::take(&mut s, "shape.rectangle"); + s.execute("shape.rectangle", &rect(9)).unwrap(); + assert!(save.restore(&mut s), "the step the limit would have let go is put back"); + assert_eq!(objects(&s, 1), 6); + for _ in 0..5 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(state(&s)["docs"][1]["art"], before["docs"][1]["art"]); +} + #[test] fn a_snapshot_too_large_to_cross_throws_instead_of_reading_as_no_document() { let mut s = two_docs(); @@ -436,6 +518,17 @@ fn commands_outside_the_documents_are_refused_in_a_batch_too() { assert_eq!(r["log"], json!([0, true])); } +#[test] +fn a_script_cannot_clear_the_undo_steps_from_before_its_run() { + let mut s = two_docs(); + let steps = s.documents()[1].history.undo.len(); + let source = "vc.run('shape.rectangle', {x:9,y:9,width:1,height:1});\ + var m = ''; try { vc.run('edit.clearHistory'); } catch (e) { m = e.message; } vc.log(m);"; + let r = run(&mut s, source).unwrap(); + assert_eq!(r["log"], json!(["a script cannot run `edit.clearHistory`"]), "{r}"); + assert_eq!(s.documents()[1].history.undo.len(), steps + 1); +} + #[test] fn the_vc_object_reads_the_active_document_and_cannot_be_changed() { let mut s = two_docs(); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f3d64f4a7..2a0a57373 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5172,6 +5172,7 @@ digits Malayalam Malaiàlam @msg a drag or an undo group is open; finish it before running a script hi ha un arrossegament o un grup de desfer obert; acabeu-lo abans d'executar un script @msg the script left a document that cannot be kept l'script ha deixat un document que no es pot conservar @msg the script's run is no longer available l'execució de l'script ja no està disponible +@msg a failed command changed undo steps that could not be put back, so the script was stopped una ordre fallida ha canviat passos de desfer que no s'han pogut restaurar, així que l'script s'ha aturat @msg a recovery copy could not be written, so the script was not run no s'ha pogut escriure una còpia de recuperació, per tant l'script no s'ha executat @msg Running a script S'està executant un script @msg Copied the script report S’ha copiat l’informe de l’script diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 797f18454..59690ce3f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4395,6 +4395,7 @@ imageTrace Outline Obrys @msg a drag or an undo group is open; finish it before running a script je otevřené tažení nebo skupina kroků zpět; před spuštěním skriptu ji dokončete @msg the script left a document that cannot be kept skript zanechal dokument, který nelze zachovat @msg the script's run is no longer available běh skriptu již není k dispozici +@msg a failed command changed undo steps that could not be put back, so the script was stopped neúspěšný příkaz změnil kroky zpět, které nebylo možné obnovit, proto byl skript zastaven @msg a recovery copy could not be written, so the script was not run záložní kopii se nepodařilo zapsat, proto skript nebyl spuštěn @msg Running a script Běží skript @msg Copied the script report Zpráva o skriptu zkopírována diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index af6e07891..8c34e8d4a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4000,6 +4000,7 @@ theme Appearance Erscheinungsbild @msg a drag or an undo group is open; finish it before running a script ein Ziehvorgang oder eine Rückgängig-Gruppe ist offen; schließen Sie sie ab, bevor Sie ein Skript ausführen @msg the script left a document that cannot be kept das Skript hat ein Dokument hinterlassen, das nicht behalten werden kann @msg the script's run is no longer available der Lauf des Skripts ist nicht mehr verfügbar +@msg a failed command changed undo steps that could not be put back, so the script was stopped ein fehlgeschlagener Befehl hat Rückgängig-Schritte geändert, die nicht wiederhergestellt werden konnten, daher wurde das Skript angehalten @msg a recovery copy could not be written, so the script was not run eine Wiederherstellungskopie konnte nicht geschrieben werden, daher wurde das Skript nicht ausgeführt @msg Running a script Skript wird ausgeführt @msg Copied the script report Skriptbericht kopiert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8cda31957..5434392ce 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2250,6 +2250,7 @@ axis Both Και τα δύο @msg a drag or an undo group is open; finish it before running a script είναι ανοιχτή μια μεταφορά ή ομάδα αναίρεσης· ολοκληρώστε την πριν εκτελέσετε σενάριο @msg the script left a document that cannot be kept το σενάριο άφησε ένα έγγραφο που δεν μπορεί να διατηρηθεί @msg the script's run is no longer available η εκτέλεση του σεναρίου δεν είναι πλέον διαθέσιμη +@msg a failed command changed undo steps that could not be put back, so the script was stopped μια εντολή που απέτυχε άλλαξε βήματα αναίρεσης που δεν ήταν δυνατό να επαναφερθούν, οπότε το σενάριο σταμάτησε @msg a recovery copy could not be written, so the script was not run δεν ήταν δυνατή η εγγραφή αντιγράφου ανάκτησης, οπότε το σενάριο δεν εκτελέστηκε @msg Running a script Εκτελείται σενάριο @msg Copied the script report Η αναφορά του σεναρίου αντιγράφηκε diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 07b6ad664..c6a71b564 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4085,6 +4085,7 @@ axis Both Ambos @msg a drag or an undo group is open; finish it before running a script hay un arrastre o un grupo de deshacer abierto; termínelo antes de ejecutar un script @msg the script left a document that cannot be kept el script dejó un documento que no se puede conservar @msg the script's run is no longer available la ejecución del script ya no está disponible +@msg a failed command changed undo steps that could not be put back, so the script was stopped un comando fallido cambió pasos de deshacer que no se pudieron restaurar, así que el script se detuvo @msg a recovery copy could not be written, so the script was not run no se pudo escribir una copia de recuperación, así que el script no se ejecutó @msg Running a script Ejecutando un script @msg Copied the script report Se copió el informe del script diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a2bfbb558..f7e755f0f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4309,6 +4309,7 @@ imageTrace Outline Ääriviiva @msg a drag or an undo group is open; finish it before running a script vetäminen tai kumousryhmä on kesken; viimeistele se ennen komentosarjan suorittamista @msg the script left a document that cannot be kept komentosarja jätti asiakirjan, jota ei voi säilyttää @msg the script's run is no longer available komentosarjan suoritus ei ole enää käytettävissä +@msg a failed command changed undo steps that could not be put back, so the script was stopped epäonnistunut komento muutti kumoamisvaiheita, joita ei voitu palauttaa, joten komentosarja pysäytettiin @msg a recovery copy could not be written, so the script was not run palautuskopiota ei voitu kirjoittaa, joten komentosarjaa ei suoritettu @msg Running a script Suoritetaan komentosarjaa @msg Copied the script report Komentosarjan raportti kopioitiin diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9539af763..5c5159f2d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4074,6 +4074,7 @@ axis Both Les deux @msg a drag or an undo group is open; finish it before running a script un glissement ou un groupe d’annulation est ouvert ; terminez-le avant d’exécuter un script @msg the script left a document that cannot be kept le script a laissé un document qui ne peut pas être conservé @msg the script's run is no longer available l’exécution du script n’est plus disponible +@msg a failed command changed undo steps that could not be put back, so the script was stopped une commande en échec a modifié des étapes d’annulation qui n’ont pas pu être rétablies, le script a donc été arrêté @msg a recovery copy could not be written, so the script was not run une copie de récupération n’a pas pu être écrite, le script n’a donc pas été exécuté @msg Running a script Exécution d’un script @msg Copied the script report Rapport du script copié diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b55907dd3..c535bcf40 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6146,6 +6146,7 @@ antiAlias Sharp शार्प @msg a drag or an undo group is open; finish it before running a script कोई ड्रैग या पूर्ववत समूह खुला है; स्क्रिप्ट चलाने से पहले उसे पूरा करें @msg the script left a document that cannot be kept स्क्रिप्ट ने ऐसा दस्तावेज़ छोड़ा है जिसे रखा नहीं जा सकता @msg the script's run is no longer available स्क्रिप्ट का रन अब उपलब्ध नहीं है +@msg a failed command changed undo steps that could not be put back, so the script was stopped एक विफल कमांड ने पूर्ववत चरण बदल दिए जिन्हें वापस नहीं रखा जा सका, इसलिए स्क्रिप्ट रोक दी गई @msg a recovery copy could not be written, so the script was not run रिकवरी प्रति नहीं लिखी जा सकी, इसलिए स्क्रिप्ट नहीं चलाई गई @msg Running a script स्क्रिप्ट चल रही है @msg Copied the script report Copied स्क्रिप्ट रिपोर्ट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d77cd233a..d7f2c9900 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4880,6 +4880,7 @@ imageTrace Outline Körvonal @msg a drag or an undo group is open; finish it before running a script húzás vagy visszavonási csoport van folyamatban; fejezze be a parancsfájl futtatása előtt @msg the script left a document that cannot be kept a parancsfájl olyan dokumentumot hagyott hátra, amely nem tartható meg @msg the script's run is no longer available a parancsfájl futása már nem érhető el +@msg a failed command changed undo steps that could not be put back, so the script was stopped egy sikertelen parancs olyan visszavonási lépéseket módosított, amelyeket nem lehetett visszaállítani, ezért a parancsfájl leállt @msg a recovery copy could not be written, so the script was not run a helyreállítási másolatot nem sikerült kiírni, ezért a parancsfájl nem futott le @msg Running a script Parancsfájl futtatása @msg Copied the script report Szkriptjelentés másolva diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index faaadc0fc..7edc0aedf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4127,6 +4127,7 @@ axis Both Entrambi @msg a drag or an undo group is open; finish it before running a script è aperto un trascinamento o un gruppo di annullamento; completalo prima di eseguire uno script @msg the script left a document that cannot be kept lo script ha lasciato un documento che non può essere conservato @msg the script's run is no longer available l'esecuzione dello script non è più disponibile +@msg a failed command changed undo steps that could not be put back, so the script was stopped un comando non riuscito ha modificato passaggi di annullamento che non è stato possibile ripristinare, quindi lo script è stato interrotto @msg a recovery copy could not be written, so the script was not run non è stato possibile scrivere una copia di recupero, quindi lo script non è stato eseguito @msg Running a script Esecuzione di uno script @msg Copied the script report Rapporto dello script copiato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 954cb80f4..04e9c773a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4836,6 +4836,7 @@ swatch Bright Violet ビビッドバイオレット @msg a drag or an undo group is open; finish it before running a script ドラッグまたは取り消しグループが開いています。スクリプトを実行する前に完了してください @msg the script left a document that cannot be kept スクリプトが保持できないドキュメントを残しました @msg the script's run is no longer available スクリプトの実行は利用できなくなりました +@msg a failed command changed undo steps that could not be put back, so the script was stopped 失敗したコマンドが元に戻せない取り消し手順を変更したため、スクリプトを停止しました @msg a recovery copy could not be written, so the script was not run 復元用のコピーを書き込めなかったため、スクリプトは実行されませんでした @msg Running a script スクリプトを実行中 @msg Copied the script report スクリプトのレポートをコピーしました diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 06e88aeac..91001ebb5 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5150,6 +5150,7 @@ imageTrace Outline კონტური @msg a drag or an undo group is open; finish it before running a script ღიაა გადათრევა ან გაუქმების ჯგუფი; დაასრულეთ სკრიპტის გაშვებამდე @msg the script left a document that cannot be kept სკრიპტმა დატოვა დოკუმენტი, რომლის შენახვა შეუძლებელია @msg the script's run is no longer available სკრიპტის გაშვება აღარ არის ხელმისაწვდომი +@msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ შეცვალა გაუქმების ნაბიჯები, რომელთა აღდგენა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა @msg a recovery copy could not be written, so the script was not run აღდგენის ასლის ჩაწერა ვერ მოხერხდა, ამიტომ სკრიპტი არ გაეშვა @msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fcadee74e..0568e4bd7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4763,6 +4763,7 @@ theme Appearance 외관 @msg a drag or an undo group is open; finish it before running a script 드래그 또는 실행 취소 그룹이 열려 있습니다. 스크립트를 실행하기 전에 완료하세요 @msg the script left a document that cannot be kept 스크립트가 유지할 수 없는 문서를 남겼습니다 @msg the script's run is no longer available 스크립트 실행을 더 이상 사용할 수 없습니다 +@msg a failed command changed undo steps that could not be put back, so the script was stopped 실패한 명령이 되돌릴 수 없는 실행 취소 단계를 변경하여 스크립트를 중지했습니다 @msg a recovery copy could not be written, so the script was not run 복구 사본을 쓸 수 없어 스크립트를 실행하지 않았습니다 @msg Running a script 스크립트 실행 중 @msg Copied the script report 스크립트 보고서를 복사했습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7c260ffcd..bb7df5984 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4218,6 +4218,7 @@ imageTrace Outline Kontur @msg a drag or an undo group is open; finish it before running a script otwarte jest przeciąganie lub grupa cofania; zakończ je przed uruchomieniem skryptu @msg the script left a document that cannot be kept skrypt pozostawił dokument, którego nie można zachować @msg the script's run is no longer available uruchomienie skryptu nie jest już dostępne +@msg a failed command changed undo steps that could not be put back, so the script was stopped nieudane polecenie zmieniło kroki cofania, których nie udało się przywrócić, więc skrypt został zatrzymany @msg a recovery copy could not be written, so the script was not run nie udało się zapisać kopii odzyskiwania, więc skrypt nie został uruchomiony @msg Running a script Uruchamianie skryptu @msg Copied the script report Skopiowano raport skryptu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 15eb0845f..33046d022 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5041,6 +5041,7 @@ axis Both Ambos @msg a drag or an undo group is open; finish it before running a script há um arraste ou um grupo de desfazer aberto; conclua-o antes de executar um script @msg the script left a document that cannot be kept o script deixou um documento que não pode ser mantido @msg the script's run is no longer available a execução do script não está mais disponível +@msg a failed command changed undo steps that could not be put back, so the script was stopped um comando que falhou alterou etapas de desfazer que não puderam ser restauradas, então o script foi interrompido @msg a recovery copy could not be written, so the script was not run não foi possível gravar uma cópia de recuperação, portanto o script não foi executado @msg Running a script Executando um script @msg Copied the script report Relatório do script copiado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8d2d42272..421623c3f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4121,6 +4121,7 @@ axis Both Оба @msg a drag or an undo group is open; finish it before running a script открыто перетаскивание или группа отмены; завершите их перед запуском скрипта @msg the script left a document that cannot be kept скрипт оставил документ, который нельзя сохранить @msg the script's run is no longer available выполнение скрипта больше недоступно +@msg a failed command changed undo steps that could not be put back, so the script was stopped неудавшаяся команда изменила шаги отмены, которые не удалось восстановить, поэтому скрипт остановлен @msg a recovery copy could not be written, so the script was not run не удалось записать копию для восстановления, поэтому скрипт не выполнен @msg Running a script Выполняется скрипт @msg Copied the script report Отчёт о сценарии скопирован diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 76bee54a5..55139c2db 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3995,6 +3995,7 @@ axis Both Båda @msg a drag or an undo group is open; finish it before running a script en dragning eller ångragrupp är öppen; avsluta den innan du kör ett skript @msg the script left a document that cannot be kept skriptet lämnade ett dokument som inte kan behållas @msg the script's run is no longer available skriptets körning är inte längre tillgänglig +@msg a failed command changed undo steps that could not be put back, so the script was stopped ett misslyckat kommando ändrade ångrasteg som inte kunde återställas, så skriptet stoppades @msg a recovery copy could not be written, so the script was not run en återställningskopia kunde inte skrivas, så skriptet kördes inte @msg Running a script Kör ett skript @msg Copied the script report Kopierade skriptrapporten diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1d6d077f6..1011a62b7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3877,6 +3877,7 @@ axis Both Her İkisi @msg a drag or an undo group is open; finish it before running a script bir sürükleme veya geri alma grubu açık; betiği çalıştırmadan önce tamamlayın @msg the script left a document that cannot be kept betik, saklanamayan bir belge bıraktı @msg the script's run is no longer available betiğin çalıştırması artık kullanılamıyor +@msg a failed command changed undo steps that could not be put back, so the script was stopped başarısız bir komut geri yüklenemeyen geri alma adımlarını değiştirdi, bu yüzden betik durduruldu @msg a recovery copy could not be written, so the script was not run kurtarma kopyası yazılamadı, bu nedenle betik çalıştırılmadı @msg Running a script Betik çalıştırılıyor @msg Copied the script report Komut dosyası raporu kopyalandı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 756803b23..37c68f7ef 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4111,6 +4111,7 @@ axis Both Обидві @msg a drag or an undo group is open; finish it before running a script відкрито перетягування або групу скасування; завершіть їх перед запуском скрипта @msg the script left a document that cannot be kept скрипт залишив документ, який не можна зберегти @msg the script's run is no longer available виконання скрипта більше не доступне +@msg a failed command changed undo steps that could not be put back, so the script was stopped невдала команда змінила кроки скасування, які не вдалося відновити, тому скрипт зупинено @msg a recovery copy could not be written, so the script was not run не вдалося записати копію для відновлення, тому скрипт не виконано @msg Running a script Виконується скрипт @msg Copied the script report Звіт про сценарій скопійовано diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a728220dc..ec51dce96 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4529,6 +4529,7 @@ theme Appearance Giao diện @msg a drag or an undo group is open; finish it before running a script đang có thao tác kéo hoặc nhóm hoàn tác; hãy hoàn thành trước khi chạy tập lệnh @msg the script left a document that cannot be kept tập lệnh để lại một tài liệu không thể giữ @msg the script's run is no longer available lần chạy tập lệnh không còn khả dụng +@msg a failed command changed undo steps that could not be put back, so the script was stopped một lệnh thất bại đã thay đổi các bước hoàn tác không thể khôi phục, nên tập lệnh đã bị dừng @msg a recovery copy could not be written, so the script was not run không thể ghi bản sao khôi phục nên tập lệnh không được chạy @msg Running a script Đang chạy tập lệnh @msg Copied the script report Đã sao chép báo cáo script diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e26fcf6ed..cde1e1e87 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4543,6 +4543,7 @@ imageTrace Outline 轮廓 @msg a drag or an undo group is open; finish it before running a script 拖动或撤销组尚未结束;请在运行脚本前完成 @msg the script left a document that cannot be kept 脚本留下了无法保留的文档 @msg the script's run is no longer available 脚本运行已不可用 +@msg a failed command changed undo steps that could not be put back, so the script was stopped 失败的命令更改了无法恢复的撤销步骤,因此脚本已停止 @msg a recovery copy could not be written, so the script was not run 无法写入恢复副本,因此未运行脚本 @msg Running a script 正在运行脚本 @msg Copied the script report 已复制脚本报告 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f8cea77f3..d80047d06 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2387,6 +2387,7 @@ file-filter Templates 範本 @msg a drag or an undo group is open; finish it before running a script 拖曳或還原群組尚未結束;請在執行指令碼前完成 @msg the script left a document that cannot be kept 指令碼留下了無法保留的文件 @msg the script's run is no longer available 指令碼執行已無法使用 +@msg a failed command changed undo steps that could not be put back, so the script was stopped 失敗的命令變更了無法還原的復原步驟,因此指令碼已停止 @msg a recovery copy could not be written, so the script was not run 無法寫入復原副本,因此未執行指令碼 @msg Running a script 正在執行指令碼 @msg Copied the script report 已複製指令碼報告 diff --git a/docs/mcp.md b/docs/mcp.md index ade82a6d2..6e78d8522 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1527,8 +1527,9 @@ change anything outside the documents: `script.*` except `script.check`, `plugin `shortcutSet.*`, `help.*`, `print.*`, `library.*`, `importMatches.*`, `edit.undoGroup.*`, `file.recovery.*`, `stroke.widthProfile.*`, saving, importing or deleting a preset, `text.addFontFiles`, `text.findFontFiles`, `text.rescanFonts`, `links.findFiles`, `file.place.queue`, `color.loadProfile`, `color.removeProfile`, `file.print`, -`view.proofSetup`, `view.proofColors`, `view.overprintPreview` and `view.separationsPreview`. Calling one throws an -error. With Data Recovery off for complex documents, a run in the web build does not start while a complex document +`view.proofSetup`, `view.proofColors`, `view.overprintPreview` and `view.separationsPreview`. It also cannot run +`edit.clearHistory`: a run keeps each document's undo steps from before it and adds one. Calling one of these throws +an error. With Data Recovery off for complex documents, a run in the web build does not start while a complex document has unsaved changes. VectorCraft sets these limits itself: From 20b9aa94016b188d4f7b0652198f55bab568d947 Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Tue, 10 Nov 2026 12:33:53 +0500 Subject: [PATCH 42/48] =?UTF-8?q?#4936:=20the=20M7=20row=20and=20the=20Typ?= =?UTF-8?q?e=20advanced=20area=20rows=20name=20Spelling=20=E2=80=BA=20Auto?= =?UTF-8?q?=20Spell=20Check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/roadmap.md | 6 +++--- docs/target-app-parity.md | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6e7c8f56a..99a3e3c9e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -141,9 +141,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), 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 | 4–8 | | 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 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c4ff51d7..a12cd960d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -215,15 +215,15 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -484,10 +484,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Appearance, transparency, graphic styles, masks | 5 | 98% | 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, Revolve (Classic), 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 and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| 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 and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; 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 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (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 is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (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 is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; 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 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | @@ -791,7 +791,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol. A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. + - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). From a0c2dbb6da26dba52a752eaa338152c7f728441d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 23:18:58 -0800 Subject: [PATCH 43/48] View: Outline draws on a white canvas, with artboards as their edges In Outline the canvas kept the Preview pasteboard color and filled each artboard with white paper. Paths and type draw as black hairlines, so art on the pasteboard was close to invisible in the dark interface brightnesses. In Outline the whole canvas is now white in every interface brightness and Canvas Color, pasteboard included. The artboards draw no paper and show as their edges, so Simulate Colored Paper, an artboard's background color and the paper of View > Hide Artboards no longer show in Outline. The artboard names and the ruler origin's crosshair draw in dark gray there. In split views and floating windows the artboard names follow that window's own Outline setting, as the canvas already did. --- crates/ui-egui/src/canvas.rs | 46 +++++-- crates/ui-egui/src/canvas/panes.rs | 5 +- crates/ui-egui/src/canvas/tests.rs | 197 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- docs/ui-parity.md | 1 + 5 files changed, 234 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ed9c62d0b..a784941eb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -543,8 +543,9 @@ fn ruler_bands(app: &VectorcraftApp, ui: &Ui, xf: &Xf, (full, rect): (egui::Rect let painter = ui.painter_at(rect); if let Some(origin) = origin_preview { let at = xf.to_screen(origin); - painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim)); - painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim)); + let color = dim_on_canvas(t, app.outline_view()); + painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, color)); + painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, color)); } // View › Rulers › Show Video Rulers counts the active artboard in video pixels. let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app)); @@ -793,8 +794,17 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } } -/// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards, of -/// document `st`, in Outline when `outline`. +/// The canvas color in Outline, whatever the interface brightness and Canvas Color: the paths draw +/// as black hairlines, and the artboards as their edges only. +const OUTLINE_CANVAS: Color32 = Color32::WHITE; + +/// The color of the artboards' names and the ruler origin's crosshair in Outline, on +/// [`OUTLINE_CANVAS`]. +const OUTLINE_DIM: Color32 = Color32::from_gray(0x4b); + +/// The pasteboard and the artboards' shadows and paper of document `st`, or in Outline (`outline`) +/// the whole canvas in [`OUTLINE_CANVAS`], with no paper drawn for the artboards: no Simulate +/// Colored Paper color, no artboard background color, and no paper with View › Hide Artboards. fn paint_paper( app: &VectorcraftApp, st: &vectorcraft_engine::DocState, @@ -805,6 +815,12 @@ fn paint_paper( (t, outline): (&Tokens, bool), ) { let doc = &st.doc; + if outline { + // The art draws in black on white over the pasteboard and the artboards alike, and the + // artboards show as their edges (`artboard_outlines`). + painter.rect_filled(rect, 0.0, OUTLINE_CANVAS); + return; + } // Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the // simulated paper colour; a white Background Contents hides the grid). // An artboard with a background color draws its paper in that color. With Simulate Colored Paper @@ -815,7 +831,7 @@ fn paint_paper( // View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038). let setup = &doc.setup; painter.rect_filled(rect, 0.0, if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }); - } else if !outline { + } else { let setup = &doc.setup; let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; @@ -833,10 +849,6 @@ fn paint_paper( } } } - } else { - for ab in &doc.artboards { - painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); - } } } @@ -1007,16 +1019,22 @@ fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: let tool_selected = artboards::tool_labelled(app); let editing_name = ui.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).map(|e| e.index)); let unlabelled = |i: usize| editing_name == Some(i) || u64::try_from(i).ok().is_some_and(|idx| tool_selected.contains(&idx)); - artboard_edges(app, doc, (painter, xf, t), active_ab, unlabelled); + artboard_edges(app, doc, (painter, xf, t), (active_ab, dim_on_canvas(t, app.outline_view())), unlabelled); } -/// The edges, bleeds, marks and names of `doc`'s artboards, `active_ab` the active one; the -/// artboards `unlabelled` gives are not named. +/// The color of the artboards' names and the ruler origin's crosshair over the pasteboard, or over +/// [`OUTLINE_CANVAS`] in Outline (`outline`). +fn dim_on_canvas(t: &Tokens, outline: bool) -> Color32 { + if outline { OUTLINE_DIM } else { t.text_dim } +} + +/// The edges, bleeds, marks and names of `doc`'s artboards, `active_ab` the active one, the names +/// in `label`; the artboards `unlabelled` gives are not named. fn artboard_edges( app: &VectorcraftApp, doc: &vectorcraft_doc::Document, (painter, xf, t): (&egui::Painter, &Xf, &Tokens), - active_ab: usize, + (active_ab, label): (usize, Color32), unlabelled: impl Fn(usize) -> bool, ) { let artboard_tool = app.session.tool_id() == "artboard"; @@ -1034,7 +1052,7 @@ fn artboard_edges( // At the artboard's own top-left corner, which a rotated view turns away from its // bounding box's (#1095). let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); - artboard_label(painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim); + artboard_label(painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), label); } } } diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 5bd974bf9..13b56e6f1 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -12,7 +12,8 @@ use super::outline::{c32, node_outline, stroke_path, too_many_anchors}; use super::view::fit_zoom; use super::{ Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key, - frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects, + dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, pos2, rulers, shows_artboards, textured_quad, vec2, + view_rects, }; use crate::doc_windows::Win; use crate::thumb_worker::ThumbWorker; @@ -315,7 +316,7 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look); } if shows_artboards(app) { - artboard_edges(app, &st.doc, (&painter, &xf, &t), v.artboard, |_| false); + artboard_edges(app, &st.doc, (&painter, &xf, &t), (v.artboard, dim_on_canvas(&t, v.outline)), |_| false); } if app.ui.view.guides { guides_of(app, st, (&painter, &xf, &t), rect); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 42778c729..3e691d5c9 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4189,6 +4189,203 @@ fn outline_is_per_document_and_new_documents_start_in_preview() { assert!(app.run("view.outline", json!({})).is_err()); } +/// One headless canvas frame → (the colors of the filled rectangles that cover the canvas, the +/// fills of the shapes laid over an artboard, the artboards' edges drawn, the colors of the +/// artboards' names). +fn canvas_look(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Vec<Color32>, Vec<Color32>, usize, Vec<Color32>) { + let s = shapes(app, ctx); + let canvas = app.canvas_rect.unwrap(); + let xf = Xf::new(canvas, app.view().unwrap()); + let quads: Vec<Vec<Pos2>> = app.session.active().unwrap().doc.artboards.iter().map(|a| xf.quad(a.rect).to_vec()).collect(); + let on = |p: &[Pos2]| quads.iter().any(|q| q.len() == p.len() && q.iter().zip(p).all(|(a, b)| (*a - *b).length() < 0.01)); + let covers: Vec<Color32> = s + .iter() + .filter_map(|s| match s { + Shape::Rect(r) if r.rect.contains_rect(canvas) => Some(r.fill), + _ => None, + }) + .collect(); + let papers: Vec<Color32> = s + .iter() + .filter_map(|s| match s { + Shape::Path(p) if p.fill != Color32::TRANSPARENT && on(&p.points) => Some(p.fill), + _ => None, + }) + .collect(); + let edges = s.iter().filter(|s| matches!(s, Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT && on(&p.points))).count(); + let names: Vec<Color32> = s + .iter() + .filter_map(|s| match s { + Shape::Text(t) if names_an_artboard(t.galley.text()) => Some(t.fallback_color), + _ => None, + }) + .collect(); + (covers, papers, edges, names) +} + +/// In Outline, in every interface brightness, color scheme and User Interface › Canvas Color, the +/// whole canvas is white, pasteboard included, so the black outlines of art on the pasteboard show. +/// The artboards draw no paper there, only their edges, and their names draw in a dark gray. In +/// Preview the pasteboard keeps its color and the artboards their white paper. +#[test] +fn outline_draws_on_a_white_canvas_with_artboards_as_edges() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + for brightness in crate::theme::Brightness::ALL { + for scheme in crate::theme::Scheme::ALL { + // Canvas Color: matching the brightness, Dark Gray and Medium Gray, as `prefs_dialog` sets it. + for canvas_color in [None, Some(Color32::from_gray(0x5a)), Some(Color32::from_gray(0x96))] { + let ctx = egui::Context::default(); + crate::theme::apply_with(&ctx, brightness, Default::default(), scheme); + if let Some(c) = canvas_color { + let mut t = Tokens::get(&ctx); + t.pasteboard = c; + ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); + } + let case = format!("{brightness:?} {scheme:?} {canvas_color:?}"); + let pasteboard = Tokens::get(&ctx).pasteboard; + assert_ne!(pasteboard, Color32::WHITE, "{case}"); + app.view_mut().unwrap().outline = false; + let (covers, papers, edges, names) = canvas_look(&mut app, &ctx); + assert_eq!(covers.last(), Some(&pasteboard), "{case}: Preview keeps the pasteboard"); + assert_eq!(papers, [Color32::WHITE; 2], "{case}: and the artboards' paper"); + assert_eq!(edges, 2, "{case}"); + assert_eq!(names, [Tokens::get(&ctx).text_dim; 2], "{case}"); + app.view_mut().unwrap().outline = true; + let (covers, papers, edges, names) = canvas_look(&mut app, &ctx); + assert_eq!(covers.last(), Some(&Color32::WHITE), "{case}: the canvas is white in Outline"); + assert!(!covers.contains(&pasteboard), "{case}: no pasteboard in Outline"); + assert!(papers.is_empty(), "{case}: no paper over the artboards in Outline: {papers:?}"); + assert_eq!(edges, 2, "{case}: the artboards show as their edges"); + assert_eq!(names, [OUTLINE_DIM; 2], "{case}: names readable on white"); + } + } + } +} + +/// In Outline the canvas stays white with Simulate Colored Paper on, with an artboard's own +/// background color, and with View › Hide Artboards, which in Preview show the paper color, the +/// artboard's color and the paper color over the whole canvas. +#[test] +fn outline_draws_no_colored_paper() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + { + let doc = std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc); + doc.setup.simulate_paper = true; + doc.setup.grid_colors = [vectorcraft_color::Color::rgb(0.9, 0.8, 0.6), vectorcraft_color::Color::rgb(0.5, 0.5, 0.5)]; + doc.artboards[1].color = Some(vectorcraft_color::Color::rgb(0.6, 0.8, 1.0)); + } + let paper = crate::panels::c32(&app.session.active().unwrap().doc.setup.paper()); + let ctx = egui::Context::default(); + crate::theme::apply(&ctx, crate::theme::Brightness::Dark); + app.view_mut().unwrap().outline = false; + let (_, papers, _, _) = canvas_look(&mut app, &ctx); + assert_eq!(papers.len(), 2); + assert_eq!(papers[0], paper, "Preview: the simulated paper"); + assert_ne!(papers[1], paper, "Preview: the artboard's own color"); + app.view_mut().unwrap().outline = true; + let (covers, papers, edges, _) = canvas_look(&mut app, &ctx); + assert_eq!(covers.last(), Some(&Color32::WHITE)); + assert!(papers.is_empty(), "{papers:?}"); + assert_eq!(edges, 2); + app.ui.view.artboards = false; + app.view_mut().unwrap().outline = false; + let (covers, _, edges, _) = canvas_look(&mut app, &ctx); + assert_eq!(covers.last(), Some(&paper), "Preview with Hide Artboards: the paper over the whole canvas"); + assert_eq!(edges, 0); + app.view_mut().unwrap().outline = true; + let (covers, papers, edges, _) = canvas_look(&mut app, &ctx); + assert_eq!(covers.last(), Some(&Color32::WHITE), "Outline with Hide Artboards"); + assert!(!covers.contains(&paper)); + assert!(papers.is_empty()); + assert_eq!(edges, 0); +} + +/// A split-view pane or floating window draws its own view's Outline, whatever the current view +/// shows: the white canvas and dark gray artboard names in Outline, and the pasteboard and the +/// theme's names in Preview. +#[test] +fn a_pane_draws_its_own_views_outline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + crate::doc_windows::new_window(&mut app).unwrap(); + let ctx = egui::Context::default(); + crate::theme::apply(&ctx, crate::theme::Brightness::Dark); + let t = Tokens::get(&ctx); + let other = (0, 1); + for pane_outline in [true, false] { + crate::doc_windows::view_mut(&mut app, other).unwrap().outline = pane_outline; + app.view_mut().unwrap().outline = !pane_outline; + let cell = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + panes::pane(&mut app, ui, other, cell); + }); + out.textures_delta.clear(); + let mut s = vec![]; + fn flat(s: Shape, out: &mut Vec<Shape>) { + match s { + Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), + s => out.push(s), + } + } + out.shapes.into_iter().for_each(|c| flat(c.shape, &mut s)); + let fills: Vec<Color32> = s.iter().filter_map(|s| if let Shape::Rect(r) = s { Some(r.fill) } else { None }).collect(); + let names: Vec<Color32> = s + .iter() + .filter_map(|s| if let Shape::Text(x) = s { names_an_artboard(x.galley.text()).then_some(x.fallback_color) } else { None }) + .collect(); + let (canvas, name) = if pane_outline { (Color32::WHITE, OUTLINE_DIM) } else { (t.pasteboard, t.text_dim) }; + assert_eq!(fills.first(), Some(&canvas), "pane in Outline: {pane_outline}"); + assert_eq!(names, [name; 2], "pane in Outline: {pane_outline}"); + } +} + +/// While the rulers' origin is dragged out of their corner, its crosshair draws over the canvas in +/// the theme's dim text color in Preview, and in Outline in the dark gray that shows on the white +/// canvas. +#[test] +fn the_ruler_origin_crosshair_shows_on_the_outline_canvas() { + use egui::Event; + for outline in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.rulers = true; + app.view_mut().unwrap().outline = outline; + let ctx = egui::Context::default(); + crate::theme::apply(&ctx, crate::theme::Brightness::Dark); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let corner = pos2(rect.left() - RULER / 2.0, rect.top() - RULER / 2.0); + let to = rect.center(); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(corner)]); + frame(&mut app, &ctx, vec![button(corner, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + let (s, mut delta) = frame_output(&mut app, &ctx); + delta.clear(); + let lines: Vec<Color32> = s + .iter() + .filter_map(|s| match s { + Shape::LineSegment { points, stroke } + if (points[0].x - to.x).abs() < 1.5 + && (points[1].x - to.x).abs() < 1.5 + && points[0].y <= rect.top() + 1.0 + && points[1].y >= rect.bottom() - 1.0 => + { + Some(stroke.color) + } + _ => None, + }) + .collect(); + let want = if outline { OUTLINE_DIM } else { Tokens::get(&ctx).text_dim }; + assert_eq!(lines, [want], "Outline: {outline}"); + frame(&mut app, &ctx, vec![button(to, false)]); + } +} + /// #3797: a preferences file saved while View › Outline was an interface setting still loads, with /// its other view settings, and its Outline value does not put a document in Outline. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 826108b5f..4a8bf384c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1006,7 +1006,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/it/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls. - **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows. - **Navigator panel options:** the Navigator panel menu's Panel Options… sets the color the view box is drawn in, from a preset list (VectorCraft's own: Light Red, the default, then the layer colors) or a custom color, and Draw Dashed Lines as Solid Lines, off by default, which draws dashed strokes without their dashes in the panel's thumbnail only (`ui.navigatorPanelOptions`; kept with the UI state; behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). -- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Its four buttons are also commands of their own (`view.firstArtboard`, `view.previousArtboard`, `view.nextArtboard`, `view.lastArtboard`), listed in Edit › Keyboard Shortcuts without a default key, so a user can give them keys such as Page Up and Page Down (#5218). Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). +- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Its four buttons are also commands of their own (`view.firstArtboard`, `view.previousArtboard`, `view.nextArtboard`, `view.lastArtboard`), listed in Edit › Keyboard Shortcuts without a default key, so a user can give them keys such as Page Up and Page Down (#5218). Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). Outline view draws on a white canvas in every interface brightness and Canvas Color, pasteboard included, with the artboards as their edges and no paper (VectorCraft's own rule), so the black outlines of art on the pasteboard show. In Presentation Mode, → and ← go to the next and the previous artboard, and a click anywhere goes to the next one, as the status bar navigator's Next and Previous do (`view.goToArtboard`): on the last artboard → and a click leave the view as it is, and so does ← on the first. The arrow keys do not move the selection there, and the tool does not receive a click, so a click selects and draws nothing (behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). Full Screen Mode and Presentation Mode put the window full screen, covering the taskbar and the window's frame, and Normal Screen Mode or Full Screen Mode with Menu Bar puts it back (#4365); a window the user made full screen stays so. An unmodified Escape in either Full Screen Mode exits to Normal Screen Mode after any open popup, focused field, tool gesture, ruler-origin drag, floating Tools panel move, edit mode, drawer, flyout or view rotation has taken its Escape; a later Escape exits when that action is finished. This is a VectorCraft choice for both full screen variants, based on the public [shortcut list](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html); the list does not specify that priority or the return mode. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 103b0d10b..7629084b8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -428,6 +428,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned | | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | +| Outline view draws on a white canvas, the artboards as their edges | done | Outline shows only the paths ([Outline mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/preview-artwork-as-outlines.html)). VectorCraft's own rule for the canvas: in every interface brightness and Canvas Color the whole canvas is white, pasteboard included; the artboards draw no paper (no Simulate Colored Paper or artboard color, and no paper with Hide Artboards); the artboard names and the ruler origin's crosshair are dark gray; split views and floating windows follow their own view. Tests `outline_draws_on_a_white_canvas_with_artboards_as_edges`, `outline_draws_no_colored_paper`, `a_pane_draws_its_own_views_outline`, `the_ruler_origin_crosshair_shows_on_the_outline_canvas` | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools runs Arrange in Grid… (grid by row, one row or one column; columns, H and V spacing, Center on Artboard; Preview), Swap Objects, Swap Objects and Sizes, Replace with Top Object… (Actual Size, Bounds or Bounds (Fit Proportionally); Keep Original; Scale Strokes & Effects; Preview), Set Size of Each… (Width and Height each ticked on or off, Constrain Width and Height Proportions with one side, the reference point, Scale Strokes & Effects; Preview) and Zoom to Selection, each one undo step | done | `addon.arrange.*`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Group Overlapping Objects groups the selected objects whose visual bounds overlap or touch (chains included, within one layer or group), each group at its frontmost member's stacking place, one undo step | done | #4928; `addon.arrange.groupOverlapping`, `crates/engine/src/cmd/addons/arrange.rs` and its tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Randomize Opacity… gives each selected object its own opacity at random between Minimum and Maximum (%), with a Seed that repeats it and Preview, one undo step | done | #4925; `addon.arrange.randomizeOpacity`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | From 59791c8c0fbc12b0941a277d8a03a820c699838a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 23:38:23 -0800 Subject: [PATCH 44/48] Dimension tool: a click on an anchor of any path takes that anchor With the Linear mode, two problems kept a click on an anchor from taking it: - A press on an anchor that moved more than 3 pixels before the release dragged out the dimension of the segment under the pointer and committed it almost on top of the art. A press on an anchor now picks it unless the pointer moves past the anchor's 8-pixel reach; a longer drag still dimensions the segment. - A pick linked only an anchor of the segment nearest the pointer. An anchor of another path near that segment, for example one inside a group lying just inside another object's edge, was taken at most as a Smart Guides point with no link, so the dimension did not follow it when the art changed. A pick now links the nearest anchor of any visible, unlocked path within reach, in groups and compound paths too. --- crates/tools/src/dimension.rs | 136 +++++++++++++++++++++++++++++++--- docs/dimensions.md | 7 +- 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 44bbbf44b..53fbd3f3f 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -67,8 +67,13 @@ pub struct DimensionTool { snap: crate::guides::DrawSnap, /// Where the next Linear pick lands, as last worked out for the pointer (for the overlays). snapped: Option<(Point, Option<PointRef>)>, + /// Whether the press under way began on an anchor a Linear click picks. + press_on_anchor: bool, } +/// How near an anchor (screen pixels) a pick snaps to it. +const ANCHOR_REACH: f64 = 8.0; + /// `at` as a Shift click picks it: the direction from `last` snapped to 45° steps (from the /// constrain angle), the distance kept. fn constrained(cx: &ToolContext, last: Point, at: Point) -> Point { @@ -119,10 +124,45 @@ fn anchor_near(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<(usize, [edge.segment, next] .into_iter() .filter_map(|i| Some((i, sp.anchors.get(i)?.p))) - .filter(|(_, a)| a.distance(p) <= cx.tol(8.0)) + .filter(|(_, a)| a.distance(p) <= cx.tol(ANCHOR_REACH)) .min_by(|(_, a), (_, b)| a.distance(p).total_cmp(&b.distance(p))) } +/// The anchor nearest `p` within reach, on any editable path (the topmost on a tie), as a link to +/// its path. Bounded work, and dimension artwork is skipped, as in [`pick`]. +fn anchor_under(cx: &ToolContext, p: Point) -> Option<(Point, PointRef)> { + type Best = Option<(f64, Point, PointRef)>; + fn visit(n: &Node, p: Point, reach: f64, budget: &mut usize, best: &mut Best) { + if *budget == 0 || !n.visible || n.locked || n.extras().dimension.is_some() { + return; + } + if let Some(path) = n.path_data() { + for (subpath, sp) in path.subpaths.iter().enumerate() { + for (anchor, a) in sp.anchors.iter().enumerate() { + if *budget == 0 { + return; + } + *budget -= 1; + let d = a.p.distance(p); + if d <= reach && best.as_ref().is_none_or(|(nearest, ..)| d < *nearest) { + *best = Some((d, a.p, PointRef { node: n.id, subpath, anchor })); + } + } + } + } + if let Some(ch) = n.children() { + for c in ch.iter().rev() { + visit(c, p, reach, budget, best); + } + } + } + let (reach, mut budget, mut best) = (cx.tol(ANCHOR_REACH), 20_000usize, None); + for n in cx.doc.layers.iter().rev() { + visit(n, p, reach, &mut budget, &mut best); + } + best.map(|(_, at, r)| (at, r)) +} + /// The point of `edge` closest to `p`. fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<Point> { use vectorcraft_geom::kurbo::ParamCurveNearest; @@ -138,25 +178,22 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<Point> { } impl DimensionTool { - /// Where a Linear pick at `p` lands: on an anchor of the line under the pointer, within reach - /// (kept as a link to its path); else where Smart Guides put it (another object's anchor or - /// centre, in line with one, or for the second point on a construction guide through the - /// first), its guides showing; else on the closest point of that line; else `p` itself. + /// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as + /// a link to its path); else where Smart Guides put it (another object's anchor or centre, in + /// line with one, or for the second point on a construction guide through the first), its + /// guides showing; else on the closest point of the line under the pointer; else `p` itself. fn snap_linear(&mut self, cx: &ToolContext, p: Point) -> (Point, Option<PointRef>) { let leave = self.points.last().map(|(first, _)| crate::guides::Leave::segment(cx, *first, false)); let guided = self.snap.hover(cx, p, &[], leave.as_ref()); - let edge = pick(cx, p); - if let Some(edge) = &edge - && let Some((anchor, point)) = anchor_near(cx, edge, p) - { + if let Some((point, reference)) = anchor_under(cx, p) { self.snap.clear(); - return (point, Some(PointRef { node: edge.node, subpath: edge.subpath, anchor })); + return (point, Some(reference)); } if guided.distance(p) > 1e-9 { return (guided, None); } // Not on an anchor or a guide: snap to the closest point of the line (#2432). - (edge.and_then(|e| nearest_on(cx, &e, p)).unwrap_or(p), None) + (pick(cx, p).and_then(|e| nearest_on(cx, &e, p)).unwrap_or(p), None) } fn target(&self, cx: &ToolContext, p: Point) -> Option<Target> { let edge = pick(cx, p)?; @@ -243,6 +280,7 @@ impl DimensionTool { fn reset(&mut self) { self.pending = None; self.press = None; + self.press_on_anchor = false; self.points.clear(); self.lines.clear(); self.dragging = false; @@ -355,6 +393,10 @@ impl Tool for DimensionTool { self.press = Some((ev.pos, None)); return self.preview(cx, ev.pos); } + // A Linear press on an anchor picks the anchor unless it drags past the anchor's + // reach: moving a few pixels before the release does not drag out the dimension of + // a segment under the pointer. + self.press_on_anchor = self.kind == Kind::Linear && anchor_under(cx, ev.pos).is_some(); self.press = Some((ev.pos, self.target(cx, ev.pos))); vec![] } @@ -364,7 +406,7 @@ impl Tool for DimensionTool { } if self.pending.is_none() && let Some((start, Some(target))) = self.press.clone() - && start.distance(ev.pos) > cx.tol(3.0) + && start.distance(ev.pos) > cx.tol(if self.press_on_anchor { ANCHOR_REACH } else { 3.0 }) && self.points.is_empty() && self.lines.is_empty() { @@ -543,6 +585,76 @@ mod tests { assert!(matches!(placed.last(), Some(Action::Commit)), "{placed:?}"); } + /// A Linear press on an anchor picks that anchor even when the pointer moves a few pixels + /// before the release: it does not drag out the dimension of a segment that ends there. Both + /// picks keep their links to the path, so the dimension measures between the two anchors. + #[test] + fn a_press_on_an_anchor_picks_it_when_the_pointer_moves() { + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y); + let mut t = DimensionTool::default(); + for ((x, y), (dx, dy)) in [((100.0, 100.0), (3.5, 1.0)), ((200.0, 200.0), (-4.0, -2.0))] { + assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, x + dx, y + dy)).is_empty(), "no segment dimension is dragged out"); + assert!(t.pointer(&c, &ev(PointerKind::Up, x + dx, y + dy)).is_empty(), "nothing is committed"); + } + let d = t.pending.as_ref().expect("both anchors were picked"); + assert_eq!((d.a, d.b), (Point::new(100.0, 100.0), Point::new(200.0, 200.0))); + let anchor = |anchor| Some(PointRef { node: id, subpath: 0, anchor }); + assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None })); + // Dragged past the anchor's reach, the press drags out the dimension of a segment there. + let mut t = DimensionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 103.0, 106.0)).is_empty()); + let acts = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 60.0)); + assert!(matches!(acts.first(), Some(Action::Begin(_))), "{acts:?}"); + assert!(matches!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 60.0)).last(), Some(Action::Commit))); + } + + /// The anchors of a live rectangle are picked the same way. + #[test] + fn a_press_on_a_live_shapes_anchor_picks_it() { + let (d, id) = doc_with_live_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = DimensionTool::default(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 100.0)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 197.0, 102.0)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 197.0, 102.0)); + let [(at, Some(r))] = t.points.as_slice() else { panic!("{:?}", t.points) }; + assert_eq!((*at, r.node), (Point::new(200.0, 100.0), id)); + } + + /// An anchor of a compound path inside nested groups, lying just inside another object's + /// edge, is picked and linked, though that edge is nearer the pointer than any of the + /// compound path's own segments. A press there that moves a few pixels picks it too. + #[test] + fn an_anchor_under_another_objects_edge_is_picked() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, NodeKind}; + use vectorcraft_geom::Rect; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (outer, inner, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mut path = vectorcraft_geom::shapes::rectangle(Rect::new(20.0, 20.0, 40.0, 40.0)); + path.subpaths.extend(vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 103.0, 180.0, 130.0)).subpaths); + let art = Node::path(compound, path, Appearance::default_art()); + let inner_group = Node::new(inner, NodeKind::Group { children: vec![Arc::new(art)], clip: false }); + let group = Node::new(outer, NodeKind::Group { children: vec![Arc::new(inner_group)], clip: false }); + d.insert(Some(l), 0, group).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let corner = (Point::new(150.0, 103.0), Some(PointRef { node: compound, subpath: 1, anchor: 0 })); + let mut t = DimensionTool::default(); + assert_eq!(t.snap_linear(&c, Point::new(151.0, 101.0)), corner); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 151.0, 101.0)); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 155.0, 100.0)).is_empty(), "no segment dimension is dragged out"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 155.0, 100.0)); + assert_eq!(t.points, vec![corner]); + } + #[test] fn placement_press_previews_without_a_prior_move() { let (d, _) = doc_with_rect(); diff --git a/docs/dimensions.md b/docs/dimensions.md index e55e698f2..1f1db9a49 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -4,8 +4,11 @@ The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers **Angular**, **Radial** and **Tool Options…** (double-clicking the tool opens Tool Options too). - **Linear:** hover a path segment and drag outwards: releasing places the dimension. Or click two - points (anchors snap, Shift constrains to 45°), then click to place. A curved segment is measured - from end to end. With Smart Guides on, both points also snap into line with other objects' + points (anchors snap, Shift constrains to 45°), then click to place. A click within 8 pixels of + an anchor of any path, in groups and compound paths too, takes that anchor, and the dimension + stays linked to it. A press on an anchor picks it when the pointer moves less than 8 pixels + before the release; a longer drag drags out the dimension of the segment under the pointer. A + curved segment is measured from end to end. With Smart Guides on, both points also snap into line with other objects' anchors and centres, and the second onto the construction guides through the first; the guides show while hovering, before each click. - **Angular:** hover a corner where two straight segments meet and drag outwards. Or click two From 9220c8526cac21ea6a36acb49b99096807bd642a Mon Sep 17 00:00:00 2001 From: HassanKhan <ch.hasni.khan@gmail.com> Date: Tue, 10 Nov 2026 12:56:29 +0500 Subject: [PATCH 45/48] #4936: the spell check revision rows go in each doc's Revision history --- docs/gaps.md | 4 ++-- docs/roadmap.md | 4 ++-- docs/target-app-parity.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index bf0dbdce7..418cd6833 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -5,8 +5,6 @@ ## Feature submission evidence **Feature evidence last updated:** 2026-11-02. -| 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | -| 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -855,6 +853,8 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | +| 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. | | 2026-11-09 | minor | G4 type: Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, #4936; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | | 2026-11-09 | minor | G5: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); Missing trimmed; estimate 12–22 → 11–21 h. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 99a3e3c9e..505717039 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,8 +42,6 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| -| 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. | -| 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 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. | @@ -194,6 +192,8 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. | +| 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. | | 2026-11-09 | minor | M7: Auto Spell Check (#4936); the dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | | 2026-11-09 | 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. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a12cd960d..431419493 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -41,8 +41,6 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | -| 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours 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. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, 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, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | @@ -1930,6 +1928,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | +| 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | | 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports (evidence in Feature revisions). Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. | From 3b1b5c7d4777eba742d1d61eacaa5bf8e22aedcc Mon Sep 17 00:00:00 2001 From: jmverges <juanmanuel.vergessolanas@gmail.com> Date: Tue, 10 Nov 2026 07:59:01 +0000 Subject: [PATCH 46/48] .ai import: the live envelope and warped type check sees white art (#6412) The check that keeps a live envelope or warped type only where it draws as the art saved with it renders each on its own, over nothing, and compared the pixels as seen on white. White art over nothing looks like nothing on white, so white contents that drew somewhere else passed and opened live in the wrong place. Pixels now also differ when they look different seen on black, so such art keeps its saved art, as colored art does. Opaque colored art is compared as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/eps/src/import/ai/envelope.rs | 10 +++--- crates/eps/src/import/ai/tests.rs | 47 +++++++++++++++++++++++++ crates/eps/src/import/native/compare.rs | 9 +++++ docs/file-format-parity.md | 3 +- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index a421bbd6a..b601d21fd 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -35,7 +35,7 @@ use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, LayerColor, Node, use vectorcraft_geom::PathData; use super::obj::Obj; -use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour}; +use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour, differ_on_black}; /// The plugin that writes envelopes. pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use @@ -325,9 +325,9 @@ fn alone(node: &Node, layer: NodeId, shared: &Shared<'_>) -> Document { } /// How `live` and `drawn`, drawn over `region` at `scale`, differ: the pixels that differ in -/// lightness or colour, those whose neighbours all differ too (where one draws something the other -/// doesn't, not an edge drawn a little apart), and the pixels either inks. `None` when they can't -/// be drawn. +/// lightness or colour, seen on white or on black (each is drawn on its own: white art shows only on +/// black), those whose neighbours all differ too (where one draws something the other doesn't, not +/// an edge drawn a little apart), and the pixels either inks. `None` when they can't be drawn. fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Option<(usize, usize, usize)> { let x = vectorcraft_render::Renderer::new().render_region(live, region, scale, false); let y = vectorcraft_render::Renderer::new().render_region(drawn, region, scale, false); @@ -336,7 +336,7 @@ fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Opt } let (w, h) = (x.width as usize, x.height as usize); let (xs, ys) = (x.pixels.as_chunks::<4>().0, y.pixels.as_chunks::<4>().0); - let differs: Vec<bool> = xs.iter().zip(ys).map(|(p, q)| differ(p, q) || differ_in_colour(p, q)).collect(); + let differs: Vec<bool> = xs.iter().zip(ys).map(|(p, q)| differ(p, q) || differ_in_colour(p, q) || differ_on_black(p, q)).collect(); let inked = xs.iter().zip(ys).filter(|(p, q)| p[3] > 8 || q[3] > 8).count(); let at = |i: usize, j: usize| differs.get(j * w + i).copied().unwrap_or(false); let any = differs.iter().filter(|d| **d).count(); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 19cd6fa27..2f0fca73b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7826,6 +7826,23 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { assert_eq!(r.warnings, [super::envelope::DRAWN]); } +/// The same, in white: white art seen on white looks like nothing, so the check sees it on black +/// too, and a white envelope that draws elsewhere keeps its drawn art. +#[test] +fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { + use vectorcraft_doc::live::EnvelopeKind; + const WHITE: &str = "0 0 0 0 1 1 1 Xa\n"; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let content = envelope_content().replace(RED, WHITE); + let drawn = drawn_content(&other).replace(RED, WHITE); + let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + /// A document of `n` warped text objects, with the art the live warp makes of each, in its look. fn warped_type_doc(n: usize) -> (Document, Vec<super::envelope::WarpedLook>) { use vectorcraft_doc::{Appearance, Effect, TextObject}; @@ -7871,6 +7888,36 @@ fn warped_type_past_the_budget_comes_in_as_its_look() { assert_eq!(kept.extras().wrap, Some(wrap)); } +/// White warped type is checked as other type is: seen on white it looks like nothing, so the check +/// sees it on black too. Type whose live warp draws as its look stays type; type whose look is +/// warped the other way comes in as its look. +#[test] +fn white_warped_type_that_draws_elsewhere_comes_in_as_its_look() { + use vectorcraft_doc::{Appearance, CharStyle, Effect, TextObject}; + let warp = |bend: f64| { + let mut appearance = Appearance::default(); + appearance.effects.push(Effect { id: "warp.arc".into(), params: serde_json::json!({"bend": bend}), visible: true }); + appearance + }; + for (look_bend, replaced) in [(40.0, 0), (-40.0, 1)] { + let mut doc = Document::new(400.0, 200.0); + let mut layer = Node::layer(doc.alloc_id(), "Art", vectorcraft_doc::LayerColor::Preset(0)); + let white = CharStyle { fill: Paint::solid(Color::WHITE), ..Default::default() }; + let t = TextObject::point(kurbo::Point::new(20.0, 60.0), "TILT", white); + let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + text.appearance = warp(40.0).into(); + let mut other = text.clone(); + other.appearance = warp(look_bend).into(); + let mut look = vectorcraft_effects::expand_outlined(None, &other, None).unwrap(); + look.id = doc.alloc_id(); + let looks = vec![super::envelope::WarpedLook { slot: text.id, look, left_out: None }]; + layer.children_mut().unwrap().push(Arc::new(text)); + doc.layers = vec![Arc::new(layer)]; + let warped = super::envelope::WarpedType { looks, checks: 1, ..Default::default() }; + assert_eq!(warped.settle(&mut doc, Some, &Default::default(), &mut vec![]), replaced, "look bent {look_bend}"); + } +} + /// The budget for warped type is what the envelopes left: their checks and the page's share they /// spent, with the page they are held to. #[test] diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index 044dbd240..99cafb771 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -56,6 +56,15 @@ pub(in crate::import) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) } +/// Do two pixels look different seen on black: in lightness? An object drawn on its own and seen +/// only on white hides what it draws in white (white type over nothing looks like nothing): seen +/// on black too, that shows. +pub(in crate::import) fn differ_on_black(p: &[u8; 4], q: &[u8; 4]) -> bool { + // Premultiplied, a pixel is what it shows on black. + let on_black = |p: &[u8; 4]| [0, 1, 2].map(|i| u32::from(p[i])); + p != q && lightness(on_black(p)).abs_diff(lightness(on_black(q))) > SAME_PIXEL +} + /// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour /// and its CMYK equivalent stay within it; a red and a dark red don't). const SAME_COLOUR: u32 = 64; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b1c305db1..029d64cc9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -327,6 +327,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | `.ai` and EPS import: the check that keeps a live envelope or warped type only where it draws as the art saved with it renders each on its own, over nothing, and compared the pixels seen on white, where white art looks like nothing. White contents that drew somewhere else passed it and opened live in the wrong place (white warped type off its banner, in users' files). Pixels now also differ when they look different seen on black, so such art keeps its saved art, as colored art does. Tests `import::ai::tests::a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art`, `white_warped_type_that_draws_elsewhere_comes_in_as_its_look` | | 2026-11-10 | minor | PDF import: an image in a DeviceN space of one colorant opens as a Separation image does (#6314): a grayscale image of its tints colorized by its ink. One of two or more colorants that are all process plates (Cyan, Magenta, Yellow, Black, and the ink `None`) opens as a CMYK image, each colorant's tint in its plate and its Decode array applied (8 or 16 bits a sample; one that can't be read, such as a JPEG, opens in RGB with the CMYK images' warning). Both used to open as RGB images, losing their inks. A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB: an image holds one ink or the process plates, not both (#6348). Tests `colorize::a_devicen_image_of_one_ink_opens_colorized_by_it`, `colorize::a_devicen_image_of_process_plates_opens_in_cmyk`, `colorize::a_duotone_devicen_image_still_opens_as_rgb` | | 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) | | 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) | From e3a17716edcf42a937f25b56db3068bad7a8b330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= <messhias@gmail.com> Date: Tue, 10 Nov 2026 08:47:45 +0000 Subject: [PATCH 47/48] G5: built-in Map Markers symbol library --- crates/engine/src/cmd/symbollib.rs | 28 ++++++++++++++++ crates/engine/src/tests/symbollib.rs | 32 ++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 5 +++ crates/ui-egui/src/i18n/bs.tsv | 5 +++ crates/ui-egui/src/i18n/ca.tsv | 5 +++ crates/ui-egui/src/i18n/cs.tsv | 5 +++ crates/ui-egui/src/i18n/da.tsv | 5 +++ crates/ui-egui/src/i18n/de.tsv | 5 +++ crates/ui-egui/src/i18n/el.tsv | 5 +++ crates/ui-egui/src/i18n/es.tsv | 5 +++ crates/ui-egui/src/i18n/fi.tsv | 5 +++ crates/ui-egui/src/i18n/fr.tsv | 5 +++ crates/ui-egui/src/i18n/hi.tsv | 5 +++ crates/ui-egui/src/i18n/hu.tsv | 5 +++ crates/ui-egui/src/i18n/id.tsv | 5 +++ crates/ui-egui/src/i18n/it.tsv | 5 +++ crates/ui-egui/src/i18n/ja.tsv | 5 +++ crates/ui-egui/src/i18n/ka.tsv | 5 +++ crates/ui-egui/src/i18n/ko.tsv | 5 +++ crates/ui-egui/src/i18n/nl.tsv | 5 +++ crates/ui-egui/src/i18n/pl.tsv | 5 +++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +++ crates/ui-egui/src/i18n/ru.tsv | 5 +++ crates/ui-egui/src/i18n/sv.tsv | 5 +++ crates/ui-egui/src/i18n/tr.tsv | 5 +++ crates/ui-egui/src/i18n/uk.tsv | 5 +++ crates/ui-egui/src/i18n/vi.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 +++ .../src/panels/tests_symbol_libraries.rs | 2 +- docs/gaps.md | 14 ++++---- docs/target-app-parity.md | 15 +++++---- 32 files changed, 211 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 1e74eb456..fe3f1149d 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -157,6 +157,7 @@ pub struct BuiltinSymbolLibrary { pub const BUILTIN: &[BuiltinSymbolLibrary] = &[ BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes }, BuiltinSymbolLibrary { id: "ui-frames", name: "UI Frames", make: ui_frames }, + BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers }, ]; /// Built-in library `id`, made once. @@ -221,6 +222,33 @@ fn ui_frames() -> SymbolLibrary { SymbolLibrary { name: "UI Frames".into(), doc } } +/// Map Markers: pin, flag, crosshair and checkpoint for map/HUD art (original, no 9-slice). +fn map_markers() -> SymbolLibrary { + let c = Point::ZERO; + let pin = [(0.0, 22.0), (-9.0, 4.0), (-12.0, -6.0), (-8.0, -16.0), (0.0, -20.0), (8.0, -16.0), (12.0, -6.0), (9.0, 4.0)]; + let flag = [(-16.0, 22.0), (-12.0, 22.0), (-12.0, -8.0), (18.0, -14.0), (-12.0, -20.0), (-12.0, -22.0), (-16.0, -22.0)]; + let pin: Vec<Point> = pin.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let flag: Vec<Point> = flag.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let markers = [ + ("Pin", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pin, true)), "#e74c3c"), + ("Flag", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&flag, true)), "#3498db"), + ("Crosshair", shapes::star(c, 20.0, 5.0, 4, 0.0), "#2c3e50"), + ("Checkpoint", shapes::polygon(c, 18.0, 4, 0.0), "#27ae60"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in markers { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + let edge = fill.lerp(&Color::BLACK, 0.35); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Map Markers".into(); + SymbolLibrary { name: "Map Markers".into(), doc } +} + /// An instance of symbol `name` (what the resource gathering follows). fn instance(name: &str) -> Node { Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }) diff --git a/crates/engine/src/tests/symbollib.rs b/crates/engine/src/tests/symbollib.rs index 42909da1c..410bd473d 100644 --- a/crates/engine/src/tests/symbollib.rs +++ b/crates/engine/src/tests/symbollib.rs @@ -84,12 +84,16 @@ fn the_builtin_library_is_original_art_generated_in_code() { let mut s = Session::new(); let list = run(&mut s, "symbol.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); - assert_eq!(libs.len(), 2, "{libs:?}"); + assert_eq!(libs.len(), 3, "{libs:?}"); assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn"))); assert_eq!( (libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()), (Some("ui-frames"), Some("UI Frames"), Some("builtIn")) ); + assert_eq!( + (libs[2]["id"].as_str(), libs[2]["name"].as_str(), libs[2]["category"].as_str()), + (Some("map-markers"), Some("Map Markers"), Some("builtIn")) + ); let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"})); let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]); @@ -125,6 +129,32 @@ fn ui_frames_symbols_carry_default_nine_slice_when_added() { )); } +#[test] +fn map_markers_symbols_add_without_nine_slice() { + let mut s = session(); + let lib = run(&mut s, "symbol.library", json!({"library": "map-markers"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Pin", "Flag", "Crosshair", "Checkpoint"]); + let mut placed = Vec::new(); + for (i, name) in names.iter().enumerate() { + let r = run( + &mut s, + "symbol.addFromLibrary", + json!({"library": "map-markers", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}), + ); + assert_eq!(r["added"], json!([name])); + placed.push(r["placed"].as_u64().unwrap()); + } + let d = doc(&s); + for name in &names { + assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides"); + } + assert!(matches!( + d.node(NodeId(placed[0])).map(|n| &n.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == "Pin" + )); +} + #[test] fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e1121be66..92c0eb459 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5844,6 +5844,11 @@ axis Y Axis Y ос Button Бутон Panel Панел UI Frames UI рамки + Map Markers Маркери на карта + Pin Карфица + Flag Флаг + Crosshair Мерник + Checkpoint Контролна точка Open Symbol Library Отваряне на библиотека със символи Save Symbol Library Запазване на библиотека със символи Save Symbol Library… Запази библиотека със символи… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d4749aba4..35a50dd07 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5888,6 +5888,11 @@ axis Y Axis Y osa Button Dugme Panel Panel UI Frames UI okviri + Map Markers Markeri mape + Pin Pin + Flag Zastava + Crosshair Nišan + Checkpoint Kontrolna tačka Open Symbol Library Otvori biblioteku simbola Save Symbol Library Sačuvaj biblioteku simbola Save Symbol Library… Sačuvaj biblioteku simbola… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 91169aaa4..0a05f93bc 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -443,6 +443,11 @@ antiAlias Sharp Nítid Button Botó Panel Panell UI Frames Marcs d’IU + Map Markers Marcadors de mapa + Pin Xinxeta + Flag Bandera + Crosshair Punt de mira + Checkpoint Punt de control Batch Lot Batch… Lot… Bearing Orientació diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 91d86612c..4db1434f7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6052,6 +6052,11 @@ axis Y Axis osa Y Button Tlačítko Panel Panel UI Frames Rámce UI + Map Markers Mapové značky + Pin Špendlík + Flag Vlajka + Crosshair Zaměřovač + Checkpoint Kontrolní bod Open Symbol Library Otevřít knihovnu symbolů Save Symbol Library Uložit knihovnu symbolů Save Symbol Library… Uložit knihovnu symbolů… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6348e68a4..e0143dd66 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5859,6 +5859,11 @@ axis Y Axis Y-akse Button Knap Panel Panel UI Frames UI-rammer + Map Markers Kortmarkører + Pin Nål + Flag Flag + Crosshair Sigtekorn + Checkpoint Kontrolpunkt Open Symbol Library Åbn symbolbibliotek Save Symbol Library Gem symbolbibliotek Save Symbol Library… Gem symbolbibliotek… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0ffe6eeb9..fda23a01f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5635,6 +5635,11 @@ axis Y Axis Y-Achse Button Schaltfläche Panel Bedienfeld UI Frames UI-Rahmen + Map Markers Kartenmarkierungen + Pin Stecknadel + Flag Flagge + Crosshair Fadenkreuz + Checkpoint Kontrollpunkt Open Symbol Library Symbol-Bibliothek öffnen Save Symbol Library Symbol-Bibliothek speichern Save Symbol Library… Symbol-Bibliothek speichern… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6dc7a40ea..387de896c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6003,6 +6003,11 @@ axis Y Axis Άξονας Y Button Κουμπί Panel Πίνακας UI Frames Πλαίσια UI + Map Markers Δείκτες χάρτη + Pin Καρφίτσα + Flag Σημαία + Crosshair Σταυρόνημα + Checkpoint Σημείο ελέγχου Open Symbol Library Άνοιγμα βιβλιοθήκης συμβόλων Save Symbol Library Αποθήκευση βιβλιοθήκης συμβόλων Save Symbol Library… Αποθήκευση βιβλιοθήκης συμβόλων… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0f3947277..7850df8cb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5994,6 +5994,11 @@ axis Y Axis Eje Y Button Botón Panel Panel UI Frames Marcos de IU + Map Markers Marcadores de mapa + Pin Chincheta + Flag Bandera + Crosshair Punto de mira + Checkpoint Punto de control Open Symbol Library Abrir biblioteca de símbolos Save Symbol Library Guardar biblioteca de símbolos Save Symbol Library… Guardar biblioteca de símbolos… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e2ab48874..b2567df75 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6082,6 +6082,11 @@ axis Y Axis Y-akseli Button Painike Panel Paneeli UI Frames UI-kehykset + Map Markers Karttamerkit + Pin Nasta + Flag Lippu + Crosshair Tähtäin + Checkpoint Tarkastuskohta Open Symbol Library Avaa symbolikirjasto Save Symbol Library Tallenna symbolikirjasto Save Symbol Library… Tallenna symbolikirjasto… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c2c333b6b..d4ed847db 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6047,6 +6047,11 @@ axis Y Axis Axe Y Button Bouton Panel Panneau UI Frames Cadres d’IU + Map Markers Marqueurs de carte + Pin Épingle + Flag Drapeau + Crosshair Réticule + Checkpoint Point de contrôle Open Symbol Library Ouvrir une bibliothèque de symboles Save Symbol Library Enregistrer la bibliothèque de symboles Save Symbol Library… Enregistrer la bibliothèque de symboles… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index d39ab857f..f1922221f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -514,6 +514,11 @@ antiAlias Sharp शार्प Button बटन Panel पैनल UI Frames UI फ़्रेम + Map Markers मानचित्र चिह्न + Pin पिन + Flag झंडा + Crosshair क्रॉसहेयर + Checkpoint चेकपॉइंट Batch बैच Batch Trace Folder फ़ोल्डर बैच ट्रेस करें Batch… बैच… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2b70f11db..463bac7cd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6035,6 +6035,11 @@ axis Y Axis Y tengely Button Gomb Panel Panel UI Frames UI keretek + Map Markers Térképjelölők + Pin Gombostű + Flag Zászló + Crosshair Célkereszt + Checkpoint Ellenőrzőpont Open Symbol Library Szimbólumkönyvtár megnyitása Save Symbol Library Szimbólumkönyvtár mentése Save Symbol Library… Szimbólumkönyvtár mentése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ed0ce6d4e..4b1502dd9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5859,6 +5859,11 @@ axis Y Axis Sumbu Y Button Tombol Panel Panel UI Frames Bingkai UI + Map Markers Penanda Peta + Pin Pin + Flag Bendera + Crosshair Bidik silang + Checkpoint Pos pemeriksaan Open Symbol Library Buka pustaka simbol Save Symbol Library Simpan pustaka simbol Save Symbol Library… Simpan pustaka simbol… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 975800f1f..854d92b40 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5669,6 +5669,11 @@ axis Y Axis Asse Y Button Pulsante Panel Pannello UI Frames Cornici UI + Map Markers Marcatori mappa + Pin Spillo + Flag Bandiera + Crosshair Mirino + Checkpoint Punto di controllo Open Symbol Library Apri libreria simboli Save Symbol Library Salva libreria simboli Save Symbol Library… Salva libreria simboli… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4e0df1a47..c35ad35cc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5734,6 +5734,11 @@ axis Y Axis Y 軸 Button ボタン Panel パネル UI Frames UIフレーム + Map Markers マップマーカー + Pin ピン + Flag 旗 + Crosshair クロスヘア + Checkpoint チェックポイント Open Symbol Library シンボルライブラリを開く Save Symbol Library シンボルライブラリを保存 Save Symbol Library… シンボルライブラリを保存… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0ff9696f7..4c3759852 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5975,6 +5975,11 @@ axis Y Axis Y ღერძი Button ღილაკი Panel პანელი UI Frames UI ჩარჩოები + Map Markers რუკის ნიშნულები + Pin ქინძისთავი + Flag დროშა + Crosshair სამიზნე + Checkpoint საკონტროლო წერტილი Open Symbol Library სიმბოლოების ბიბლიოთეკის გახსნა Save Symbol Library სიმბოლოების ბიბლიოთეკის შენახვა Save Symbol Library… სიმბოლოების ბიბლიოთეკის შენახვა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b68b4b308..885b90381 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5630,6 +5630,11 @@ axis Y Axis Y축 Button 버튼 Panel 패널 UI Frames UI 프레임 + Map Markers 지도 마커 + Pin 핀 + Flag 깃발 + Crosshair 십자선 + Checkpoint 체크포인트 Open Symbol Library 심볼 라이브러리 열기 Save Symbol Library 심볼 라이브러리 저장 Save Symbol Library… 심볼 라이브러리 저장… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 005972780..f37225fef 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5910,6 +5910,11 @@ axis Y Axis Y-as Button Knop Panel Paneel UI Frames UI-kaders + Map Markers Kaartmarkeringen + Pin Speld + Flag Vlag + Crosshair Vizier + Checkpoint Controlepunt Open Symbol Library Symboolbibliotheek openen Save Symbol Library Symboolbibliotheek opslaan Save Symbol Library… Symboolbibliotheek opslaan… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index feb3d8308..8840b28eb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5906,6 +5906,11 @@ axis Y Axis Oś Y Button Przycisk Panel Panel UI Frames Ramki UI + Map Markers Znaczniki mapy + Pin Pinezka + Flag Flaga + Crosshair Celownik + Checkpoint Punkt kontrolny Open Symbol Library Otwórz bibliotekę symboli Save Symbol Library Zapisz bibliotekę symboli Save Symbol Library… Zapisz bibliotekę symboli… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e0e83aaec..d0a1f5a81 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5967,6 +5967,11 @@ axis Y Axis Eixo Y Button Botão Panel Painel UI Frames Molduras de IU + Map Markers Marcadores de mapa + Pin Alfinete + Flag Bandeira + Crosshair Mira + Checkpoint Ponto de verificação Open Symbol Library Abrir Biblioteca de Símbolos Save Symbol Library Salvar Biblioteca de Símbolos Save Symbol Library… Salvar Biblioteca de Símbolos… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7ccd9c605..5b6f91220 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5663,6 +5663,11 @@ axis Y Axis Ось Y Button Кнопка Panel Панель UI Frames UI-рамки + Map Markers Маркеры карты + Pin Булавка + Flag Флаг + Crosshair Прицел + Checkpoint Контрольная точка Open Symbol Library Открыть библиотеку символов Save Symbol Library Сохранить библиотеку символов Save Symbol Library… Сохранить библиотеку символов… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a732ff6c0..d637a33e0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6075,6 +6075,11 @@ axis Y Axis Y-axel Button Knapp Panel Panel UI Frames UI-ramar + Map Markers Kartmarkörer + Pin Nål + Flag Flagga + Crosshair Hårkors + Checkpoint Kontrollpunkt Open Symbol Library Öppna symbolbibliotek Save Symbol Library Spara symbolbibliotek Save Symbol Library… Spara symbolbibliotek… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dad8615f5..e6f96e7a8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6008,6 +6008,11 @@ axis Y Axis Y ekseni Button Düğme Panel Panel UI Frames UI çerçeveleri + Map Markers Harita İşaretleri + Pin İğne + Flag Bayrak + Crosshair Nişangah + Checkpoint Kontrol noktası Open Symbol Library Sembol Kitaplığını Aç Save Symbol Library Sembol Kitaplığını Kaydet Save Symbol Library… Sembol Kitaplığını Kaydet… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3fcc681b5..2d0fe9859 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5649,6 +5649,11 @@ axis Y Axis Вісь Y Button Кнопка Panel Панель UI Frames UI-рамки + Map Markers Маркери карти + Pin Шпилька + Flag Прапор + Crosshair Приціл + Checkpoint Контрольна точка Open Symbol Library Відкрити бібліотеку символів Save Symbol Library Зберегти бібліотеку символів Save Symbol Library… Зберегти бібліотеку символів… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5151b4a57..762b7a9f7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5650,6 +5650,11 @@ axis Y Axis Trục Y Button Nút Panel Bảng UI Frames Khung UI + Map Markers Đánh dấu bản đồ + Pin Ghim + Flag Cờ + Crosshair Tâm ngắm + Checkpoint Điểm kiểm tra Open Symbol Library Mở thư viện ký hiệu Save Symbol Library Lưu thư viện ký hiệu Save Symbol Library… Lưu thư viện ký hiệu… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e527dbb1f..749efbdbe 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5890,6 +5890,11 @@ axis Y Axis Y 轴 Button 按钮 Panel 面板 UI Frames UI 框架 + Map Markers 地图标记 + Pin 图钉 + Flag 旗帜 + Crosshair 准星 + Checkpoint 检查点 Open Symbol Library 打开符号库 Save Symbol Library 保存符号库 Save Symbol Library… 保存符号库… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 16e6898bf..182dd95f3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5687,6 +5687,11 @@ axis Y Axis Y 軸 Button 按鈕 Panel 面板 UI Frames UI 框架 + Map Markers 地圖標記 + Pin 圖釘 + Flag 旗幟 + Crosshair 準星 + Checkpoint 檢查點 Open Symbol Library 開啟符號資料庫 Save Symbol Library 儲存符號資料庫 Save Symbol Library… 儲存符號資料庫… diff --git a/crates/ui-egui/src/panels/tests_symbol_libraries.rs b/crates/ui-egui/src/panels/tests_symbol_libraries.rs index 6482bf312..7dee49df2 100644 --- a/crates/ui-egui/src/panels/tests_symbol_libraries.rs +++ b/crates/ui-egui/src/panels/tests_symbol_libraries.rs @@ -113,7 +113,7 @@ fn the_window_menu_and_the_symbols_panel_open_symbol_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Symbol Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.symbolLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames")]); + assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers")]); let has = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(has("window.symbolLibrary.other"), Some(true)); assert_eq!(has("ui.saveSymbolLibrary"), Some(false), "no symbols to save yet"); diff --git a/docs/gaps.md b/docs/gaps.md index 0b910710f..2910585f6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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; estimate 1–2 h → 0; 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: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; 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, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; 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; estimate 1–2 h → 0; 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: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; 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, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -97,7 +97,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) | | 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) | | 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) | -| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 11–21 | no | [target-app-parity.md](target-app-parity.md) | +| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 10–20 | no | [target-app-parity.md](target-app-parity.md) | | 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) | | 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) | | 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) | @@ -628,8 +628,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi symbol static drops them; stored in the native format. Color eyedropper for scatter, art and pattern brushes (Hue Shift colorization; samples canvas art); symbol libraries ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol - Libraries Menu open each library in a library panel of its own (two original libraries generated in code, - Basic Shapes and UI Frames with default 9-slice guides on Panel, Button and Bar; User Defined; Other + Libraries Menu open each library in a library panel of its own (three original libraries generated in code, + Basic Shapes, UI Frames with default 9-slice guides on Panel, Button and Bar, and Map Markers with Pin, + Flag, Crosshair and Checkpoint; User Defined; Other Library… reads the symbols of any document VectorCraft opens); a click adds a symbol to the document's Symbols with its Symbol Options and the resources it uses, as one undo step, and dragging one onto the canvas also places it; Save Symbols / Save Symbol Library writes the @@ -653,10 +654,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi (`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`, Eyedropper), for that instance only. - **Missing:** more built-in brush and graphic-style libraries (and further symbol libraries beyond Basic - Shapes and UI Frames; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; + Shapes, UI Frames and Map Markers; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). -- **Estimate:** 11–21 h. +- **Estimate:** 10–20 h. ## G3 3D and Materials @@ -854,6 +855,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | G5: built-in Map Markers symbol library (Pin, Flag, Crosshair, Checkpoint; no default 9-slice); Missing trimmed; estimate 11–21 → 10–20 h. | | 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c76d781c8..1f80c9956 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-10 · **Change:** 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-10 · **Change:** minor (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) ## Feature submission evidence @@ -214,16 +214,16 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | @@ -487,7 +487,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (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 is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; 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 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | @@ -530,7 +530,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~32% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have two (Basic Shapes, UI Frames with default 9-slice) and brush libraries four (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~34% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have three (Basic Shapes, UI Frames with default 9-slice, Map Markers) and brush libraries four (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -777,7 +777,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them. - - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in libraries, Basic Shapes and UI Frames (Panel, Button and Bar with default 9-slice guides), are original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. + - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in libraries, Basic Shapes, UI Frames (Panel, Button and Bar with default 9-slice guides) and Map Markers (Pin, Flag, Crosshair and Checkpoint, no default 9-slice), are original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a `<use>`, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. - **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`. @@ -1930,6 +1930,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Bundled content: built-in Map Markers symbol library (Pin, Flag, Crosshair, Checkpoint); symbol libraries evidence updated; ~32% → ~34%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | From db97802ce677e0fbc47f950dba02d57c6f336425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= <messhias@gmail.com> Date: Tue, 10 Nov 2026 08:58:20 +0000 Subject: [PATCH 48/48] G5: record Map Markers on the M10 milestone --- docs/roadmap.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 115ba760d..7204ed34b 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-10 · **Change:** 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-10 · **Change:** 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) > Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). @@ -144,11 +144,11 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, three generated libraries (Basic Shapes, UI Frames, Map Markers), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, three generated libraries (Basic Shapes, UI Frames, Map Markers), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | @@ -192,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M10/G5: Map Markers built-in symbol library (Pin, Flag, Crosshair, Checkpoint); estimates unchanged | | 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. |