From 3a711184ec41ec2053fff125c790f04bb5dee9f6 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:53:38 +0430 Subject: [PATCH 01/40] Fix #5239: double-clicking a ruler adds a guide there A double-click on the top ruler adds a vertical guide at the pointer's x, one on the left ruler a horizontal guide at its y, through guide.add (one undo step, shown even with guides hidden, an artboard guide with the Artboard tool as a drag from a ruler makes one). --- crates/engine/src/tooling.rs | 12 +++++-- crates/ui-egui/src/canvas/grid.rs | 21 +++++++++++- crates/ui-egui/src/canvas/tests.rs | 51 ++++++++++++++++++++++++++++++ docs/gaps.md | 4 ++- docs/target-app-parity.md | 4 ++- docs/ui-parity.md | 5 ++- 6 files changed, 90 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 4eb191229..e221f9f00 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -465,6 +465,14 @@ impl Session { Ok(()) } + /// The artboard a guide made from a ruler belongs to: with the Artboard tool, its active + /// artboard (an artboard guide); with any other tool, none (a canvas guide). + pub fn ruler_guide_artboard(&self) -> Option { + let boards = self.active().map_or(0, |d| d.doc.artboards.len()); + let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten(); + active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards) + } + /// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a /// horizontal one out of the top ruler. `ev` is the pointer in document space (a drag, then the /// release), over the canvas or not (`on_canvas`). The guide shows where the pointer is over @@ -475,9 +483,7 @@ impl Session { let g = match self.ruler_guide.take() { Some(g) => Some(g), None => { - let boards = self.active().map_or(0, |d| d.doc.artboards.len()); - let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten(); - let artboard = active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards); + let artboard = self.ruler_guide_artboard(); self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::NewGuide::new(cx, vertical, artboard))) } }; diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 09749fc12..06fc2e61f 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -178,7 +178,10 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] { /// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's /// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left /// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else, -/// it makes none. The corner drags the rulers' origin or resets it on a double-click. +/// it makes none. A double-click on a ruler makes a guide at the pointer, across the ruler's +/// axis (a vertical one on the top ruler, a horizontal one on the left), unsnapped, as one +/// `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a +/// double-click. pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option { let [top, left, corner] = ruler_rects(full); // Right-click on a ruler or the origin box: the document units, to swap between them as @@ -189,6 +192,22 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c // (right). Not focusable (`click_and_drag` is): no key acts on a ruler, so Tab skips it. let resp = ui.interact(r, egui::Id::unique(id), Sense::CLICK | Sense::DRAG); resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); + if resp.double_clicked_by(egui::PointerButton::Primary) + && let Some(p) = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())) + { + // The same document point the drag path reads; the guide runs across the other axis + // than the ruler, so it takes the pointer's coordinate along the ruler. + let at = xf.to_doc(p); + let mut params = json!({"vertical": !vertical, "pos": if vertical { at.y } else { at.x }}); + if let Some(ab) = app.session.ruler_guide_artboard() { + params["artboard"] = json!(ab); + } + // The new guide shows even if guides were hidden. + app.ui.view.guides = true; + if let Err(e) = app.run("guide.add", params) { + app.status(e.to_string()); + } + } let kind = if resp.drag_stopped() { PointerKind::Up } else if resp.dragged() { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 0c9462818..98123464c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -8859,3 +8859,54 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra assert_eq!(set_count, 1, "settled frame 10 is uploaded to texture"); assert_eq!(app.canvas.key.as_ref().unwrap().revision, 10, "settled frame 10 is uploaded"); } + +/// #5239: a double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler +/// at its x, a horizontal one on the left ruler at its y, as one undo step, shown even when +/// guides were hidden. The first click of the pair adds nothing. +#[test] +fn double_clicking_a_ruler_adds_a_guide_at_that_position() { + // The ruler to click, the guide's orientation and its position, the pointer at that position. + for (top, vertical, pos) in [(true, true, 120.0), (false, false, 80.0)] { + 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.ui.view.guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 2.0; + view.rotation = 0.0; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let at = if top { + pos2(xf.to_screen(Point::new(pos, 0.0)).x, rect.top() - RULER / 2.0) + } else { + pos2(rect.left() - RULER / 2.0, xf.to_screen(Point::new(0.0, pos)).y) + }; + let guides = |app: &mut VectorcraftApp| { + let list = app.session.execute("guide.list", &json!({})).unwrap(); + list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect::>() + }; + let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = steps(&app); + let click = |app: &mut VectorcraftApp| { + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + click(&mut app); + assert_eq!(guides(&mut app), vec![], "a single click adds none"); + click(&mut app); + let got = guides(&mut app); + assert_eq!(got.len(), 1, "top ruler: {top}, {got:?}"); + assert!(got[0].0 == vertical && (got[0].1 - pos).abs() < 0.01, "top ruler: {top}, want vertical {vertical} at {pos}: {got:?}"); + assert!(app.ui.view.guides, "the new guide is shown"); + assert_eq!(steps(&app), before + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(guides(&mut app), vec![]); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 8b707fd9f..040d7d8b3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a double-click on a ruler adds a guide there, #5239; scores and estimates unchanged; previously: new gradients follow document color mode) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -28,6 +28,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. | | 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. | @@ -597,6 +598,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** Selection artwork drag across visible document tabs (400 ms hover); lossless target copy, source/clipboard preservation and cancellation regressions. Cross-window dragging remains separate. +- **Done (#5239):** a double-click on a ruler adds a guide at the pointer (vertical on the top ruler, horizontal on the left one) through `guide.add`, unsnapped and in one undo step; no scored gap or estimate reassessed. - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4370a3a0..396a9525c 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-03 · **Change:** minor (new gradients follow document color mode; 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) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a double-click on a ruler adds a guide there, #5239; scores and estimates unchanged; previously: new gradients follow document color mode) · **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) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -21,6 +21,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | @@ -531,6 +532,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). - **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode. - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. +- **Ruler double-click (#5239):** a double-click on the top ruler adds a vertical guide at the pointer's x and one on the left ruler a horizontal guide at its y, through `guide.add` in one undo step; the guide is not snapped, is shown even when guides were hidden, and is an artboard guide with the Artboard tool, as a drag from a ruler makes one. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe the gesture, so it follows the request in #5239. An egui frame test covers both rulers, a lone click, and Undo. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 26d3dd679..d7bd60bd4 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-03 · **Change:** minor (Type on a Path Options has a Preview checkbox, #5360; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a double-click on a ruler adds a guide there, #5239; scores and estimates unchanged; previously: Type on a Path Options has a Preview checkbox, #5360) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -30,6 +30,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-03 | minor | Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | @@ -174,6 +175,7 @@ tools, never checked side by side) isn't captured row by row. | Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set; no View › Snapping submenu. Floating-panel parity remains unverified. | | Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only | | Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides | +| A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler, exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); the first click of the pair adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position` | | Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | | Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | | Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | @@ -418,6 +420,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-03 | minor | Enclosing Lasso selects fully enclosed objects with a freehand loop under Object › Plug-ins; scores and estimates unchanged. | | 2026-11-03 | minor | Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); scores and estimates unchanged | | 2026-11-02 | minor | Font-family fields in Character, Properties and Control accept the wheel without opening the menu (#5253); scores and estimates unchanged. | From a0ccbdbd58f48eb128717a3f1c5d482580f355bd Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:33:24 +0430 Subject: [PATCH 02/40] Fix #5239: a ruler double-click needs both clicks on that ruler and follows a rotated view (review) --- crates/ui-egui/src/canvas/grid.rs | 31 ++++++++-- crates/ui-egui/src/canvas/tests.rs | 94 ++++++++++++++++++++++++++++++ docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 06fc2e61f..7f000ee78 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -178,9 +178,10 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] { /// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's /// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left /// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else, -/// it makes none. A double-click on a ruler makes a guide at the pointer, across the ruler's -/// axis (a vertical one on the top ruler, a horizontal one on the left), unsnapped, as one -/// `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a +/// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the +/// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the +/// left; swapped in a view turned 90°, where the rulers read the other document axis, and the +/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a /// double-click. pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option { let [top, left, corner] = ruler_rects(full); @@ -192,13 +193,31 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c // (right). Not focusable (`click_and_drag` is): no key acts on a ruler, so Tab skips it. let resp = ui.interact(r, egui::Id::unique(id), Sense::CLICK | Sense::DRAG); resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); + // egui counts a double-click by position and time alone, so a click on the canvas right + // at the ruler and one on the ruler would pass for one: the pair counts only when the + // ruler's own click before it came within the double-click delay. + let last_click = egui::Id::unique(if vertical { "ruler-left-last-click" } else { "ruler-top-last-click" }); + let now = ui.input(|i| i.time); + let first_here = + ui.data(|d| d.get_temp::(last_click)).is_some_and(|t| now - t <= ui.ctx().options(|o| o.input_options.max_double_click_delay)); + if resp.clicked_by(egui::PointerButton::Primary) { + ui.data_mut(|d| d.insert_temp(last_click, now)); + } if resp.double_clicked_by(egui::PointerButton::Primary) + && first_here && let Some(p) = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())) { - // The same document point the drag path reads; the guide runs across the other axis - // than the ruler, so it takes the pointer's coordinate along the ruler. + // The same document point the drag path reads. A ruler reads the canvas's coordinates + // turned with the view, so the guide is the one whose line crosses the ruler's axis: + // the vertical one when that axis (the screen's x for the top ruler, y for the left, + // in document directions) is nearer the document's x, else the horizontal one. At 0° + // that is a vertical guide from the top ruler; at 90° a horizontal one; between, the + // nearer axis, whose line still runs through the point and never along the ruler. let at = xf.to_doc(p); - let mut params = json!({"vertical": !vertical, "pos": if vertical { at.y } else { at.x }}); + let (sn, cs) = xf.rot.sin_cos(); + let axis = if vertical { (sn, cs) } else { (cs, -sn) }; + let across = axis.0.abs() >= axis.1.abs(); + let mut params = json!({"vertical": across, "pos": if across { at.x } else { at.y }}); if let Some(ab) = app.session.ruler_guide_artboard() { params["artboard"] = json!(ab); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 98123464c..7c2355cbe 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -8910,3 +8910,97 @@ fn double_clicking_a_ruler_adds_a_guide_at_that_position() { assert_eq!(guides(&mut app), vec![]); } } + +/// A fresh app with a 400 x 300 document, rulers on, the view at zoom 2 turned `rotation` degrees, +/// and its first frame drawn. +fn ruler_guide_app(rotation: f64) -> (VectorcraftApp, egui::Context) { + 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.ui.view.guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 2.0; + view.rotation = rotation; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + (app, ctx) +} + +/// One primary click at `at`, as three frames (move, press, release). +fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, ctx, vec![button(true)]); + frame(app, ctx, vec![button(false)]); +} + +/// The ruler guides as (vertical, position). +fn guide_list(app: &mut VectorcraftApp) -> Vec<(bool, f64)> { + let list = app.session.execute("guide.list", &json!({})).unwrap(); + list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect() +} + +/// #5239 review: egui counts a double-click by position and time, not by widget, so a click on +/// the canvas right at a ruler followed at once by a click on the ruler is not a double-click on +/// the ruler. No guide; two clicks on the ruler still make one. +#[test] +fn a_canvas_click_then_a_ruler_click_adds_no_guide() { + for (top, canvas_first) in [(true, true), (false, true), (true, false), (false, false)] { + let (mut app, ctx) = ruler_guide_app(0.0); + let rect = app.canvas_rect.unwrap(); + // Two points less than 6 pt apart, either side of the ruler's inner edge. + let (on_canvas, on_ruler) = if top { + (pos2(rect.center().x, rect.top() + 1.0), pos2(rect.center().x, rect.top() - 1.0)) + } else { + (pos2(rect.left() + 1.0, rect.center().y), pos2(rect.left() - 1.0, rect.center().y)) + }; + click_at(&mut app, &ctx, if canvas_first { on_canvas } else { on_ruler }); + click_at(&mut app, &ctx, on_ruler); + let want = usize::from(!canvas_first); + assert_eq!(guide_list(&mut app).len(), want, "top ruler: {top}, first click on the canvas: {canvas_first}"); + } +} + +/// #5239 review: the rulers read the canvas's coordinates turned with the view, so a guide made +/// by a double-click runs across the ruler's axis in any view: it passes through the clicked +/// point and crosses the ruler's edge at it, never along it. At 90° the top ruler reads the +/// document's y, so its guide is horizontal; the left ruler reads x, so its guide is vertical. +/// A view turned part of the way takes the document axis nearer to the ruler's. +#[test] +fn a_ruler_double_click_follows_a_rotated_view() { + // Rotation (degrees), top ruler's guide is vertical, left ruler's guide is vertical. + for (rotation, top_vertical, left_vertical) in [ + (0.0, true, false), + (90.0, false, true), + (-90.0, false, true), + (180.0, true, false), + (270.0, false, true), + (30.0, true, false), + (60.0, false, true), + ] { + for top in [true, false] { + let (mut app, ctx) = ruler_guide_app(rotation); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let at = + if top { pos2(rect.center().x + 37.0, rect.top() - RULER / 2.0) } else { pos2(rect.left() - RULER / 2.0, rect.center().y + 23.0) }; + click_at(&mut app, &ctx, at); + click_at(&mut app, &ctx, at); + let got = guide_list(&mut app); + let label = format!("rotation {rotation}, top ruler: {top}: {got:?}"); + assert_eq!(got.len(), 1, "{label}"); + let (vertical, pos) = got[0]; + assert_eq!(vertical, if top { top_vertical } else { left_vertical }, "{label}"); + // Through the clicked point. + let doc = xf.to_doc(at); + assert!((pos - if vertical { doc.x } else { doc.y }).abs() < 0.01, "{label}: through {doc:?}"); + // Its direction on screen is not along the ruler: the line crosses the ruler's edge. + let along = if vertical { Point::new(doc.x, doc.y + 1.0) } else { Point::new(doc.x + 1.0, doc.y) }; + let dir = (xf.to_screen(along) - xf.to_screen(doc)).normalized(); + let across = if top { dir.y.abs() } else { dir.x.abs() }; + assert!(across > 0.5, "{label}: the guide runs along the ruler ({dir:?})"); + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 040d7d8b3..da2b04969 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -598,7 +598,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** Selection artwork drag across visible document tabs (400 ms hover); lossless target copy, source/clipboard preservation and cancellation regressions. Cross-window dragging remains separate. -- **Done (#5239):** a double-click on a ruler adds a guide at the pointer (vertical on the top ruler, horizontal on the left one) through `guide.add`, unsnapped and in one undo step; no scored gap or estimate reassessed. +- **Done (#5239):** a double-click on a ruler adds a guide at the pointer (vertical on the top ruler, horizontal on the left one, swapped in a view turned 90°; both clicks on that ruler) through `guide.add`, unsnapped and in one undo step; no scored gap or estimate reassessed. - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 396a9525c..3647a4240 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -532,7 +532,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). - **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode. - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. -- **Ruler double-click (#5239):** a double-click on the top ruler adds a vertical guide at the pointer's x and one on the left ruler a horizontal guide at its y, through `guide.add` in one undo step; the guide is not snapped, is shown even when guides were hidden, and is an artboard guide with the Artboard tool, as a drag from a ruler makes one. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe the gesture, so it follows the request in #5239. An egui frame test covers both rulers, a lone click, and Undo. +- **Ruler double-click (#5239):** a double-click (both clicks on that ruler) on the top ruler adds a vertical guide at the pointer's x and one on the left ruler a horizontal guide at its y (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), through `guide.add` in one undo step; the guide is not snapped, is shown even when guides were hidden, and is an artboard guide with the Artboard tool, as a drag from a ruler makes one. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe the gesture, so it follows the request in #5239. egui frame tests cover both rulers, a lone click, Undo, a canvas click before a ruler click, and rotated views. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d7bd60bd4..9b62219c1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -175,7 +175,7 @@ tools, never checked side by side) isn't captured row by row. | Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set; no View › Snapping submenu. Floating-panel parity remains unverified. | | Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only | | Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides | -| A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler, exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); the first click of the pair adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position` | +| A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` | | Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | | Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | | Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | From 65238286158c90ec84d2e6abca22ba083bb26d28 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:51:03 +0430 Subject: [PATCH 03/40] docs: restore the status lines damaged by merges (main's line plus this PR's note) --- docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index bfded0581..3608d4298 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (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; a double-click on a ruler adds a guide there, #5239; scores and estimates unchanged; previously: new gradients follow document color mode) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (double-clicking a ruler adds a guide (#5239); 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 84b64806a..a714e9538 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-03 · **Change:** minor (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; Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; a double-click on a ruler adds a guide there, #5239; scores and estimates unchanged; previously: new gradients follow document color mode) · **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) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (double-clicking a ruler adds a guide (#5239); 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) ## Feature submission evidence diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bbd649b81..474885899 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-03 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged; Type on a Path Options has a Preview checkbox, #5360; the Action Options row cites the public Actions panel page; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; a double-click on a ruler adds a guide there, #5239; scores and estimates unchanged; previously: Type on a Path Options has a Preview checkbox, #5360) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (double-clicking a ruler adds a guide (#5239); Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence From 397f5b64c8be8c9814099a5265f1d34009d12a7c Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:44:29 +0430 Subject: [PATCH 04/40] Merge fixup: close the Cmd-held bounding box test the merge left open --- crates/ui-egui/src/canvas/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index ccd062e66..c4fef86bd 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10092,6 +10092,7 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() { assert!(handle_drawn(&mut app, egui::Modifiers::COMMAND), "Cmd held shows the Selection tool's box"); assert!(!handle_drawn(&mut app, egui::Modifiers::NONE), "gone once Cmd is released"); assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged"); +} /// #5239: a double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler /// at its x, a horizontal one on the left ruler at its y, as one undo step, shown even when From df0373aa266428f3e0b574c81b86b6581a10d98d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:14:01 -0800 Subject: [PATCH 05/40] Scripts: alert, confirm and prompt open dialogs with Stop, and headless runs take their answers from script.run alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) are globals in a script run. In the app the run waits at the call for the person's answer, spends no instructions while it waits, and every dialog has Stop, which ends the run and puts every document back. A run over MCP or the command line takes its answers in order from script.run's answers, a list of {kind, value} records; a missing, mismatched or unused answer ends the run. The report lists each dialog with its answer in dialogs, with dialogCount and dialogsTruncated. The interpreter gains a host call that waits at the call for its result, from the script's own code only; a dialog called in a callback of a built-in, a getter or a conversion throws in the app. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/scripts.rs | 11 +- crates/engine/src/script/console.rs | 2 +- crates/engine/src/script/dialogs.rs | 364 +++++++++++++++++++++ crates/engine/src/script/mod.rs | 58 +++- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/script_dialogs.rs | 289 ++++++++++++++++ crates/js/src/heap.rs | 5 +- crates/js/src/host.rs | 86 ++++- crates/js/src/host_tests.rs | 78 +++++ crates/js/src/interp.rs | 15 +- crates/js/src/lib.rs | 2 +- crates/js/src/realm.rs | 52 ++- crates/ui-egui/src/app.rs | 6 +- crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/dialogs/script_run.rs | 179 +++++++++- 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/script_runs.rs | 37 ++- crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/script_dialogs.rs | 256 +++++++++++++++ docs/mcp.md | 57 +++- docs/target-app-parity.md | 1 + 47 files changed, 1482 insertions(+), 49 deletions(-) create mode 100644 crates/engine/src/script/dialogs.rs create mode 100644 crates/engine/src/tests/script_dialogs.rs create mode 100644 crates/ui-egui/src/tests/script_dialogs.rs diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 1ed75194b..525d6d3a4 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "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 and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; 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, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error)}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, fuelLeft, fuelUsed, hostCalls and the fields above", + "{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?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), 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 and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter or a conversion and throws there, and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; 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, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's text (past these a RangeError), 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, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), @@ -122,10 +122,9 @@ fn run(s: &mut Session, p: &Value) -> Result { 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 answers = script::dialogs::parse_answers(p.get("answers")).map_err(param)?; + if s.script_frames && !answers.is_empty() { + return Err(param("`answers` is only for a run with nobody to ask: in the app, the person answers each dialog")); } let fuel = max_fuel(p)?; let text = match (path, source) { @@ -153,7 +152,7 @@ fn run(s: &mut Session, p: &Value) -> Result { _ => 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 })?; + s.script_start(script::Request { name: name.clone(), source: text, args, fuel, answers })?; if s.script_frames { return Ok(json!({"running": true, "name": name})); } diff --git a/crates/engine/src/script/console.rs b/crates/engine/src/script/console.rs index eda6feeb4..5a3b40ffe 100644 --- a/crates/engine/src/script/console.rs +++ b/crates/engine/src/script/console.rs @@ -207,7 +207,7 @@ impl Console { /// `c` as the console shows it: a control character as its Control Picture, a line or paragraph /// separator as U+2424, anything else as it is. -fn shown(c: char) -> char { +pub(crate) fn shown(c: char) -> char { match c { '\u{0}'..='\u{1f}' => char::from_u32(0x2400 + c as u32).unwrap_or('\u{fffd}'), '\u{7f}' => '\u{2421}', diff --git a/crates/engine/src/script/dialogs.rs b/crates/engine/src/script/dialogs.rs new file mode 100644 index 000000000..8800c47a0 --- /dev/null +++ b/crates/engine/src/script/dialogs.rs @@ -0,0 +1,364 @@ +//! `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and +//! `prompt(message, preset[, title])`: the dialogs a script opens while it runs. +//! +//! A message and a preset arrive as the string `String(value)` gives, a title the same way unless +//! it is left out or `undefined` ([`DEFAULT_TITLE`]), and `errorIcon` and `noAsDflt` as the +//! boolean `Boolean(value)` gives. `alert` returns `undefined`, `confirm` `true` for Yes and +//! `false` for No, and `prompt` the text in its field for OK and `null` for Cancel. +//! +//! In the app the run waits at the call for the person's answer, and spends no fuel while it +//! waits; every dialog also has Stop, which ends the run and puts everything back. A run with +//! nobody to ask takes its answers, in order, from `script.run`'s `answers`; a call with no +//! answer left, or with the next answer for another kind of dialog, ends the run. The report's +//! `dialogs` lists each dialog and its answer. +//! +//! The values here are VectorCraft's own limits: [`MAX_MESSAGE_BYTES`], [`MAX_TITLE_BYTES`], +//! [`MAX_PROMPT_BYTES`], [`MAX_ANSWERS`], [`MAX_ANSWERS_BYTES`], [`MAX_RECORDS`] and +//! [`MAX_RECORD_BYTES`]. + +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; + +use serde_json::{Map, Value, json}; +use vectorcraft_js::{Arg, Data, HostError, HostFunction, Realm, RunError}; + +use super::{Host, console}; + +/// The most bytes (UTF-8) of a dialog's message. +pub const MAX_MESSAGE_BYTES: usize = 16 << 10; +/// The most bytes (UTF-8) of a dialog's title. +pub const MAX_TITLE_BYTES: usize = 256; +/// The most bytes (UTF-8) of a prompt's preset and of the text it returns. +pub const MAX_PROMPT_BYTES: usize = 16 << 10; +/// The most answers `script.run` takes. +pub const MAX_ANSWERS: usize = 10_000; +/// The most bytes of `answers`, as JSON. +pub const MAX_ANSWERS_BYTES: usize = 1 << 20; +/// The most dialogs a report lists. +pub const MAX_RECORDS: usize = 10_000; +/// The most bytes of the dialogs a report lists, as JSON. +pub const MAX_RECORD_BYTES: usize = 1 << 20; +/// A dialog's title when the script gives none, in English; the app translates it when it shows it. +pub const DEFAULT_TITLE: &str = "Script Alert"; + +/// Which dialog a script opened. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DialogKind { + Alert, + Confirm, + Prompt, +} + +impl DialogKind { + /// The function's name, which is also the `kind` of an answer and of a report's dialog. + pub fn id(self) -> &'static str { + match self { + Self::Alert => "alert", + Self::Confirm => "confirm", + Self::Prompt => "prompt", + } + } +} + +/// A dialog a script opened, which waits for its answer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScriptDialog { + pub kind: DialogKind, + pub message: String, + /// `None`: the script gave no title ([`DEFAULT_TITLE`]). + pub title: Option, + /// `alert`: show the error icon. + pub error_icon: bool, + /// `confirm`: No is the default button. + pub no_as_default: bool, + /// `prompt`: the text its field starts with. + pub preset: String, +} + +/// The answer to a [`ScriptDialog`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DialogAnswer { + /// OK, for `alert`. + Ok, + /// Yes (`true`) or No, for `confirm`. + Confirm(bool), + /// OK with the field's text, or Cancel (`None`), for `prompt`. + Prompt(Option), +} + +impl DialogAnswer { + pub fn kind(&self) -> DialogKind { + match self { + Self::Ok => DialogKind::Alert, + Self::Confirm(_) => DialogKind::Confirm, + Self::Prompt(_) => DialogKind::Prompt, + } + } + + /// What the function returns. + fn data(&self) -> Data { + match self { + Self::Ok => Data::Undefined, + Self::Confirm(b) => Data::Bool(*b), + Self::Prompt(Some(t)) => Data::String(t.clone()), + Self::Prompt(None) => Data::Null, + } + } + + /// The answer as a report gives it: `"ok"`, a boolean, a string or `null`. + fn json(&self) -> Value { + match self { + Self::Ok => json!("ok"), + Self::Confirm(b) => json!(b), + Self::Prompt(t) => json!(t), + } + } +} + +/// `text` as a dialog shows it. With `lines`, a line feed, a carriage return or the two together +/// is a line break; every other control character, and with `lines` false every line break too, +/// shows as its Unicode Control Picture, as the Script Console shows it. +pub fn shown_text(text: &str, lines: bool) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' if lines => { + chars.next_if_eq(&'\n'); + out.push('\n'); + } + '\n' if lines => out.push('\n'), + c => out.push(console::shown(c)), + } + } + out +} + +/// The longest start of `text` that is at most `max` bytes, cut at a character boundary. +pub fn cut_to(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut end = max; + while !text.is_char_boundary(end) { + end -= 1; + } + text.get(..end).unwrap_or_default() +} + +/// `script.run`'s `answers`: a list of `{kind: "alert", value: "ok"}`, `{kind: "confirm", value: +/// true | false}` and `{kind: "prompt", value: text | null}`. An empty object, which the parameter +/// took before dialogs existed, is an empty list. +pub(crate) fn parse_answers(v: Option<&Value>) -> Result, String> { + let list = match v { + None => return Ok(Vec::new()), + Some(Value::Object(o)) if o.is_empty() => return Ok(Vec::new()), + Some(Value::Array(a)) => a, + Some(_) => return Err("`answers` must be a list of answers".into()), + }; + if list.len() > MAX_ANSWERS { + return Err(format!("`answers` has more than {MAX_ANSWERS} answers")); + } + if serde_json::to_vec(list).map_or(true, |b| b.len() > MAX_ANSWERS_BYTES) { + return Err("`answers` is larger than 1 MiB".into()); + } + list.iter().enumerate().map(|(i, a)| answer(a).map_err(|e| format!("`answers[{i}]` {e}"))).collect() +} + +fn answer(a: &Value) -> Result { + let Some(o) = a.as_object() else { return Err("must be an object with `kind` and `value`") }; + if o.len() != 2 || !o.contains_key("value") { + return Err("must have exactly `kind` and `value`"); + } + match (o.get("kind").and_then(Value::as_str), &o["value"]) { + (Some("alert"), Value::String(s)) if s == "ok" => Ok(DialogAnswer::Ok), + (Some("alert"), _) => Err("is an alert's answer, whose `value` must be \"ok\""), + (Some("confirm"), Value::Bool(b)) => Ok(DialogAnswer::Confirm(*b)), + (Some("confirm"), _) => Err("is a confirm's answer, whose `value` must be true or false"), + (Some("prompt"), Value::Null) => Ok(DialogAnswer::Prompt(None)), + (Some("prompt"), Value::String(s)) if s.len() <= MAX_PROMPT_BYTES => Ok(DialogAnswer::Prompt(Some(s.clone()))), + (Some("prompt"), Value::String(_)) => Err("is longer than 16 KiB"), + (Some("prompt"), _) => Err("is a prompt's answer, whose `value` must be a string or null"), + _ => Err("must have `kind` alert, confirm or prompt"), + } +} + +/// The dialogs of one run: the answers left for a run with nobody to ask, the dialog that waits +/// for the person's answer, and the list the report gives. +pub(crate) struct Dialogs { + /// Answers come from the person, not from `answers`. + framed: bool, + /// The realm's [`Realm::wait_flag`]. + can_wait: Rc>, + answers: RefCell>, + waiting: RefCell>, + /// The error that ended the run when an answer could not reach the script. + failed: RefCell>, + records: RefCell>, + record_bytes: Cell, + count: Cell, + truncated: Cell, +} + +impl Dialogs { + pub(crate) fn new(framed: bool, realm: &Realm, answers: Vec) -> Self { + Self { + framed, + can_wait: realm.wait_flag(), + answers: RefCell::new(answers.into()), + waiting: RefCell::default(), + failed: RefCell::default(), + records: RefCell::default(), + record_bytes: Cell::new(0), + count: Cell::new(0), + truncated: Cell::new(false), + } + } + + /// The dialog that waits for its answer. + pub(crate) fn waiting(&self) -> Option { + self.waiting.borrow().clone() + } + + pub(crate) fn is_waiting(&self) -> bool { + self.waiting.borrow().is_some() + } + + /// The error that ended the run when its answer could not reach the script. + pub(crate) fn take_failure(&self) -> Option { + self.failed.borrow_mut().take() + } + + /// How many of `answers` the run did not use. + pub(crate) fn unused(&self) -> usize { + self.answers.borrow().len() + } + + /// Gives the waiting dialog `answer`, which must be for its kind and, for a prompt, at most + /// [`MAX_PROMPT_BYTES`]. → false when it is not, or nothing waits. + pub(crate) fn answer(&self, realm: &mut Realm, answer: DialogAnswer) -> bool { + let fits = match &answer { + DialogAnswer::Prompt(Some(t)) => t.len() <= MAX_PROMPT_BYTES, + _ => true, + }; + let mut waiting = self.waiting.borrow_mut(); + let Some(d) = waiting.as_ref().filter(|d| d.kind == answer.kind() && fits) else { return false }; + self.record(d, answer.json()); + *waiting = None; + if let Err(e) = realm.answer(answer.data()) { + *self.failed.borrow_mut() = Some(e); + } + true + } + + /// The run ends while a dialog waits: the report lists it with the answer `"stop"`. + pub(crate) fn stopped(&self) { + if let Some(d) = self.waiting.borrow_mut().take() { + self.record(&d, json!("stop")); + } + } + + fn record(&self, d: &ScriptDialog, answer: Value) { + self.count.set(self.count.get().saturating_add(1)); + let mut r = Map::new(); + r.insert("kind".into(), json!(d.kind.id())); + r.insert("message".into(), json!(d.message)); + r.insert("title".into(), json!(d.title.as_deref().unwrap_or(DEFAULT_TITLE))); + match d.kind { + DialogKind::Alert => r.insert("errorIcon".into(), json!(d.error_icon)), + DialogKind::Confirm => r.insert("noAsDefault".into(), json!(d.no_as_default)), + DialogKind::Prompt => r.insert("preset".into(), json!(d.preset)), + }; + r.insert("answer".into(), answer); + let r = Value::Object(r); + let bytes = serde_json::to_vec(&r).map_or(usize::MAX, |b| b.len()); + let mut records = self.records.borrow_mut(); + if self.truncated.get() || records.len() >= MAX_RECORDS || self.record_bytes.get().saturating_add(bytes) > MAX_RECORD_BYTES { + self.truncated.set(true); + return; + } + self.record_bytes.set(self.record_bytes.get() + bytes); + records.push(r); + } + + /// Adds `dialogs`, `dialogCount` and `dialogsTruncated` to a report. + pub(crate) fn report(&self, report: &mut Map) { + report.insert("dialogs".into(), Value::Array(self.records.borrow().clone())); + report.insert("dialogCount".into(), json!(self.count.get())); + report.insert("dialogsTruncated".into(), json!(self.truncated.get())); + } +} + +/// Adds `alert`, `confirm` and `prompt` to the realm. +pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { + use Arg::{Bool, Text, TextOrUndefined}; + let call = |kind: DialogKind| -> HostFunction { + let h = host.clone(); + Rc::new(move |args| open(&h, kind, &args)) + }; + realm.define_host_function("alert", vec![Text, TextOrUndefined, Bool], call(DialogKind::Alert))?; + realm.define_host_function("confirm", vec![Text, Bool, TextOrUndefined], call(DialogKind::Confirm))?; + realm.define_host_function("prompt", vec![Text, Text, TextOrUndefined], call(DialogKind::Prompt)) +} + +fn text(args: &[Data], i: usize) -> Option { + match args.get(i) { + Some(Data::String(s)) => Some(s.clone()), + _ => None, + } +} + +fn flag(args: &[Data], i: usize) -> bool { + matches!(args.get(i), Some(Data::Bool(true))) +} + +/// A call of `alert`, `confirm` or `prompt`. +fn open(host: &Host, kind: DialogKind, args: &[Data]) -> Result { + let name = kind.id(); + host.charge_call(args.iter().map(super::data_size).sum())?; + let Some(message) = text(args, 0) else { return Err(HostError::Type(format!("{name}: give a message"))) }; + let (title, error_icon, no_as_default, preset) = match kind { + DialogKind::Alert => (text(args, 1), flag(args, 2), false, String::new()), + DialogKind::Confirm => (text(args, 2), false, flag(args, 1), String::new()), + DialogKind::Prompt => { + let Some(preset) = text(args, 1) else { return Err(HostError::Type(format!("{name}: give the text the field starts with"))) }; + (text(args, 2), false, false, preset) + } + }; + if message.len() > MAX_MESSAGE_BYTES { + return Err(HostError::Range(format!("{name}: the message is longer than 16 KiB"))); + } + if title.as_ref().is_some_and(|t| t.len() > MAX_TITLE_BYTES) { + return Err(HostError::Range(format!("{name}: the title is longer than 256 bytes"))); + } + if preset.len() > MAX_PROMPT_BYTES { + return Err(HostError::Range(format!("{name}: the text the field starts with is longer than 16 KiB"))); + } + let dialog = ScriptDialog { kind, message, title, error_icon, no_as_default, preset }; + let d = &host.dialogs; + if d.framed { + if !d.can_wait.get() { + return Err(HostError::Error(format!( + "{name}: a dialog cannot open inside a callback of a built-in function, a getter or a conversion; call it from the script's own code" + ))); + } + *d.waiting.borrow_mut() = Some(dialog); + return Err(HostError::Wait); + } + let next = d.answers.borrow_mut().pop_front(); + match next { + Some(a) if a.kind() == kind => { + d.record(&dialog, a.json()); + Ok(a.data()) + } + Some(a) => { + d.record(&dialog, json!("missing")); + Err(HostError::Abort(format!("{name}: the next answer in `answers` is for {}, not {name}", a.kind().id()))) + } + None => { + d.record(&dialog, json!("missing")); + Err(HostError::Abort(format!("{name}: `answers` has no answer left for this dialog"))) + } + } +} diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 772f2c016..2ca777096 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -27,8 +27,14 @@ //! there too. The session's console gets a start line, the run's output, its uncaught error, an end //! line and a report line; the run's report holds its own output and error, its time, its host //! calls by kind and its slowest commands. +//! +//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the +//! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`. pub mod console; +pub mod dialogs; + +pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog}; use std::cell::{Cell, RefCell}; use std::collections::HashMap; @@ -80,7 +86,7 @@ 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", "console"]; +pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs"]; /// The most commands a run's report lists in `slowestCommands`. pub const SLOWEST_COMMANDS: usize = 5; @@ -135,6 +141,8 @@ pub(crate) struct Request { pub source: String, pub args: Map, pub fuel: u64, + /// The answers to the dialogs of a run with nobody to ask, in order. + pub answers: Vec, } thread_local! { @@ -468,6 +476,8 @@ struct Host { commands: RefCell>, /// The time the run's slices took, in nanoseconds. run_nanos: Cell, + /// `alert`, `confirm` and `prompt`: their answers and the list the report gives. + dialogs: dialogs::Dialogs, } /// A run's host calls by kind, as its report gives them in `hostCallCounts`. @@ -975,7 +985,7 @@ fn kind_id(kind: &RunErrorKind) -> &'static str { /// `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"]; + const HOST_OBJECTS: &[&str] = &["app", "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 = @@ -1031,6 +1041,23 @@ impl Session { !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 dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's + /// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it. + pub fn script_dialog(&self) -> Option { + if self.script_stop.is_set() { + return None; + } + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.dialogs.waiting())).flatten() + } + + /// Gives the dialog the running script waits at its answer; the next [`Session::script_pump`] + /// goes on from the call. → false when nothing waits, the answer is for another kind of + /// dialog, or a prompt's text is longer than [`dialogs::MAX_PROMPT_BYTES`]. + pub fn script_answer(&mut self, answer: DialogAnswer) -> bool { + let Some(k) = self.script_key.as_ref().filter(|_| !self.script_stop.is_set()) else { return false }; + with_job(k, |j| j.host.dialogs.answer(&mut j.realm, answer)).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())) @@ -1132,6 +1159,7 @@ impl Session { counts: Counts::default(), commands: RefCell::default(), run_nanos: Cell::new(0), + dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers), }); 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") })?; @@ -1140,6 +1168,7 @@ impl Session { let started = realm .define_host_object("vc", vc_object(&host, args)) .and_then(|()| realm.define_host_object("$", dollar_object(&host))) + .and_then(|()| dialogs::install(&mut realm, &host)) .and_then(|()| realm.start(&request.source)); if let Err(e) = started { let mut report = failure_report(&request.name, &e, &request.source, &host, 0, 0); @@ -1163,11 +1192,20 @@ impl Session { 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() { + if let Some(e) = job.host.dialogs.take_failure() { + return Some(self.script_failed(job, &e)); + } + if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); self.script_key = Some(key); return None; } + // A stop while a dialog waits ends the run there; the interpreter cannot go on from the call. + if job.realm.waits() { + let kind = LimitKind::Interrupted; + let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 }; + return Some(self.script_failed(job, &e)); + } if vectorcraft_doc::cancel::check().is_err() { self.script_stop.set(); } @@ -1184,6 +1222,12 @@ impl Session { self.script_key = Some(key); None } + Ok(Ok(Progress::Done(_))) if job.host.dialogs.unused() > 0 => { + let n = job.host.dialogs.unused(); + let kind = LimitKind::Host; + let message = format!("`answers` has {n} answer{} the script did not use", if n == 1 { "" } else { "s" }); + Some(self.script_failed(job, &RunError { kind: RunErrorKind::Limit(kind), message, line: 0, column: 0 })) + } Ok(Ok(Progress::Done(_))) => { let r = self.script_commit(job); self.trim_histories(); @@ -1193,6 +1237,7 @@ impl Session { } fn script_failed(&mut self, job: Job, e: &RunError) -> Result { + job.host.dialogs.stopped(); let fuel_left = job.realm.fuel_left(); let mut report = failure_report(&job.name, e, "", &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); close_run(self, &job.host, &mut report, ending(&e.kind)); @@ -1288,7 +1333,7 @@ impl Session { if let Some(result) = self.script_pump() { return result; } - if self.script_waiting() { + if self.script_waiting() || self.script_dialog().is_some() { self.script_stop.set(); } } @@ -1347,8 +1392,8 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) { /// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run /// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report -/// line in the session's console. Adds the run's time, host calls by kind, slowest commands and -/// console to `report`. +/// line in the session's console. Adds the run's time, host calls by kind, slowest commands, +/// console and dialogs to `report`. fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::Ending) { let mut pending = std::mem::take(&mut *host.pending.borrow_mut()); if !pending.is_empty() { @@ -1379,6 +1424,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: o.insert("hostCallCounts".into(), host.counts.json()); o.insert("slowestCommands".into(), Value::Array(slow_json)); o.insert("console".into(), host.console.borrow().report()); + host.dialogs.report(o); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7f0d450c7..f1f77160c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -151,6 +151,7 @@ mod save; mod saveoptions; mod scalestrokes; mod script_console; +mod script_dialogs; mod script_run; mod scripts; mod select_ids_first; diff --git a/crates/engine/src/tests/script_dialogs.rs b/crates/engine/src/tests/script_dialogs.rs new file mode 100644 index 000000000..7572ca52f --- /dev/null +++ b/crates/engine/src/tests/script_dialogs.rs @@ -0,0 +1,289 @@ +//! `alert`, `confirm` and `prompt` in a script: their arguments and results, the answers a run with +//! nobody to ask takes from `answers`, the wait in the app, Stop, and the report's `dialogs`. + +use serde_json::{Value, json}; + +use crate::script::{DialogAnswer, DialogKind, ScriptDialog, dialogs}; +use crate::*; + +/// One open document with one rectangle. +fn one_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s +} + +fn art(s: &Session) -> (String, usize) { + let d = &s.documents()[0]; + (format!("{:?}", d.doc.layers), d.history.undo.len()) +} + +fn headless(s: &mut Session, source: &str, answers: Value) -> Result { + s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": answers})) +} + +/// A run in the app of `source`, one slice in. +fn framed(s: &mut Session, source: &str) { + s.script_frames = true; + assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true); + assert!(s.script_pump().is_none()); +} + +/// Pumps the run in the app until it ends or waits at a dialog (`None`). +fn pump(s: &mut Session) -> Option> { + for _ in 0..10_000 { + if s.script_dialog().is_some() { + return None; + } + if let Some(r) = s.script_pump() { + return Some(r); + } + } + panic!("the run neither ended nor waited"); +} + +fn answers(list: &[Value]) -> Value { + Value::Array(list.to_vec()) +} + +#[test] +fn notification_signatures_and_returns() { + let mut s = one_doc(); + let source = "vc.log(typeof alert('a')); alert(42, 'Title', 1);\ + vc.log(confirm('c')); vc.log(confirm('n', true, undefined));\ + vc.log(prompt('p', 'pre')); vc.log(prompt({toString: function () { return 'own'; }}, '', 'T'));"; + let r = headless( + &mut s, + source, + answers(&[ + json!({"kind": "alert", "value": "ok"}), + json!({"kind": "alert", "value": "ok"}), + json!({"kind": "confirm", "value": true}), + json!({"kind": "confirm", "value": false}), + json!({"kind": "prompt", "value": "typed"}), + json!({"kind": "prompt", "value": null}), + ]), + ) + .unwrap(); + assert_eq!(r["log"], json!(["undefined", true, false, "typed", null]), "{r}"); + assert_eq!( + r["dialogs"], + json!([ + {"kind": "alert", "message": "a", "title": "Script Alert", "errorIcon": false, "answer": "ok"}, + {"kind": "alert", "message": "42", "title": "Title", "errorIcon": true, "answer": "ok"}, + {"kind": "confirm", "message": "c", "title": "Script Alert", "noAsDefault": false, "answer": true}, + {"kind": "confirm", "message": "n", "title": "Script Alert", "noAsDefault": true, "answer": false}, + {"kind": "prompt", "message": "p", "title": "Script Alert", "preset": "pre", "answer": "typed"}, + {"kind": "prompt", "message": "own", "title": "T", "preset": "", "answer": null}, + ]) + ); + assert_eq!((r["dialogCount"].clone(), r["dialogsTruncated"].clone()), (json!(6), json!(false))); + let has = headless(&mut s, "vc.log(vc.api.has('dialogs'));", json!([])).unwrap(); + assert_eq!(has["log"], json!([true])); +} + +#[test] +fn headless_answers_follow_call_order() { + let mut s = one_doc(); + let source = "var out = []; [1, 2].forEach(function (i) { out.push(confirm('q' + i)); }); out.push(prompt('r', 'x')); vc.log(out.join());"; + let r = headless( + &mut s, + source, + answers(&[json!({"kind": "confirm", "value": false}), json!({"kind": "confirm", "value": true}), json!({"kind": "prompt", "value": "z"})]), + ) + .unwrap(); + assert_eq!(r["log"], json!(["false,true,z"]), "a callback of a built-in takes its answer too: {r}"); + assert_eq!(headless(&mut s, "1", json!({})).unwrap()["dialogCount"], 0, "an empty object is an empty list"); +} + +#[test] +fn missing_or_mismatched_answer_rolls_back() { + let mut s = one_doc(); + let before = art(&s); + let edit = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});"; + for (source, given, says) in [ + (format!("{edit} try {{ alert('x'); }} catch (e) {{ vc.log('caught'); }}"), json!([]), "no answer left"), + (format!("{edit} alert('x');"), answers(&[json!({"kind": "confirm", "value": true})]), "is for confirm, not alert"), + (format!("{edit} alert('x');"), answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})]), "1 answer"), + ] { + let e = headless(&mut s, &source, given).unwrap_err().to_string(); + assert!(e.contains(says), "{e}"); + assert_eq!(art(&s), before, "{source}"); + let report = s.script_report().unwrap(); + assert_eq!(report["kind"], "limit", "{report}"); + assert_eq!(report["log"], json!([]), "a missing answer cannot be caught"); + assert_eq!(report["dialogCount"], 1); + } + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "ok"); + headless(&mut s, "alert('x')", json!([])).unwrap_err(); + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "missing"); +} + +#[test] +fn invalid_answers_are_rejected_before_run() { + let mut s = one_doc(); + let before = art(&s); + let many: Vec = (0..=dialogs::MAX_ANSWERS).map(|_| json!({"kind": "alert", "value": "ok"})).collect(); + let big: Vec = (0..80).map(|_| json!({"kind": "prompt", "value": "x".repeat(16_000)})).collect(); + for given in [ + json!({"a": 1}), + json!("ok"), + json!([1]), + json!([{"kind": "alert"}]), + json!([{"kind": "alert", "value": true}]), + json!([{"kind": "confirm", "value": "yes"}]), + json!([{"kind": "prompt", "value": 3}]), + json!([{"kind": "prompt", "value": "x".repeat(dialogs::MAX_PROMPT_BYTES + 1)}]), + json!([{"kind": "other", "value": "ok"}]), + json!([{"kind": "alert", "value": "ok", "extra": 1}]), + Value::Array(many), + Value::Array(big), + ] { + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});"; + let r = s.execute("script.run", &json!({"source": source, "answers": given})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); + assert_eq!(art(&s), before); + } + s.script_frames = true; + let r = s.execute("script.run", &json!({"source": "1", "answers": [{"kind": "alert", "value": "ok"}]})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "a run in the app takes no answers: {r:?}"); + assert!(!s.script_running()); +} + +#[test] +fn a_framed_run_waits_at_each_dialog_and_takes_the_persons_answer() { + let mut s = one_doc(); + framed( + &mut s, + "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); var a = confirm('Go?', true, 'Ask'); var b = prompt('Name', 'n'); vc.log([a, b]);", + ); + let want = ScriptDialog { + kind: DialogKind::Confirm, + message: "Go?".into(), + title: Some("Ask".into()), + error_icon: false, + no_as_default: true, + preset: String::new(), + }; + assert_eq!(s.script_dialog(), Some(want)); + assert!(!s.script_answer(DialogAnswer::Ok), "an answer for another kind of dialog is refused"); + assert!(s.script_answer(DialogAnswer::Confirm(true))); + assert!(!s.script_answer(DialogAnswer::Confirm(true)), "nothing waits until the run goes on"); + assert!(pump(&mut s).is_none()); + assert_eq!(s.script_dialog().unwrap().preset, "n"); + assert!(!s.script_answer(DialogAnswer::Prompt(Some("x".repeat(dialogs::MAX_PROMPT_BYTES + 1))))); + assert!(s.script_answer(DialogAnswer::Prompt(Some("Ann".into())))); + let r = pump(&mut s).unwrap().unwrap(); + assert_eq!(r["log"], json!([[true, "Ann"]])); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script", "a finished run is one undo step"); + assert_eq!(r["dialogs"][1]["answer"], "Ann"); +} + +#[test] +fn prompt_cancel_returns_null() { + let mut s = one_doc(); + framed(&mut s, "vc.log(prompt('Name', '') === null);"); + assert!(s.script_answer(DialogAnswer::Prompt(None))); + assert_eq!(pump(&mut s).unwrap().unwrap()["log"], json!([true])); +} + +#[test] +fn dialog_loop_stops_or_exhausts() { + let mut s = one_doc(); + let before = art(&s); + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); while (1) { alert('x'); }"; + let three: Vec = (0..3).map(|_| json!({"kind": "alert", "value": "ok"})).collect(); + let e = headless(&mut s, source, Value::Array(three)).unwrap_err().to_string(); + assert!(e.contains("no answer left"), "{e}"); + assert_eq!(s.script_report().unwrap()["dialogCount"], 4); + assert_eq!(art(&s), before); + framed(&mut s, source); + for _ in 0..3 { + assert!(s.script_answer(DialogAnswer::Ok)); + assert!(pump(&mut s).is_none()); + } + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert_eq!(s.script_dialog(), None, "a run asked to stop no longer waits"); + assert!(!s.script_answer(DialogAnswer::Ok)); + assert!(s.script_pump().unwrap().is_err()); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), report["dialogCount"].as_u64()), (Some("stopped"), Some(4)), "{report}"); + assert_eq!(report["dialogs"][3]["answer"], "stop"); + assert_eq!(art(&s), before, "Stop at the first or any later dialog leaves no undo step"); + framed(&mut s, source); + assert!(s.script_abandon(), "quitting while a dialog waits abandons the run"); + assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "stop"); + assert_eq!(art(&s), before); +} + +#[test] +fn dialog_text_limits_and_controls() { + let mut s = one_doc(); + let source = format!( + "var out = [];\ + function t(f) {{ try {{ f(); out.push('opened'); }} catch (e) {{ out.push(e.name); }} }}\ + t(function () {{ alert('x'); }});\ + t(function () {{ alert(new Array({m}).join('m')); }});\ + t(function () {{ alert('m', new Array({t}).join('t')); }});\ + t(function () {{ prompt('m', new Array({m}).join('p')); }});\ + t(function () {{ alert(); }});\ + t(function () {{ prompt('m'); }});\ + t(function () {{ alert({{toString: function () {{ throw new Error('no'); }}}}); }});\ + t(function () {{ alert(undefined); }});\ + vc.log(out.join());", + m = dialogs::MAX_MESSAGE_BYTES + 2, + t = dialogs::MAX_TITLE_BYTES + 2, + ); + let r = headless(&mut s, &source, answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})])).unwrap(); + assert_eq!(r["log"], json!(["opened,RangeError,RangeError,RangeError,TypeError,TypeError,Error,opened"]), "{r}"); + assert_eq!(r["dialogs"][1]["message"], "undefined", "an explicit undefined message is a message"); + assert_eq!(dialogs::shown_text("a\r\nb\rc\nd\te\u{7}\u{7f}", true), "a\nb\nc\nd\u{2409}e\u{2407}\u{2421}"); + assert_eq!(dialogs::shown_text("a\nb", false), "a\u{240a}b", "a title stays on one line"); + assert_eq!(dialogs::cut_to("aé", 2), "a", "a cut keeps whole characters"); +} + +#[test] +fn dialog_transcript_on_success_and_failure() { + let mut s = one_doc(); + let r = headless(&mut s, "alert('one'); throw new Error('later');", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap_err(); + assert!(r.to_string().contains("later")); + let report = s.script_report().unwrap(); + assert_eq!(report["dialogs"], json!([{"kind": "alert", "message": "one", "title": "Script Alert", "errorIcon": false, "answer": "ok"}])); + let ok = headless(&mut s, "1", json!([])).unwrap(); + assert_eq!((ok["dialogs"].clone(), ok["dialogCount"].clone()), (json!([]), json!(0))); +} + +#[test] +fn dialog_wait_does_not_spend_fuel() { + let mut s = one_doc(); + framed(&mut s, "alert('x'); 1"); + for _ in 0..20 { + assert!(s.script_pump().is_none(), "the run waits"); + } + assert!(s.script_answer(DialogAnswer::Ok)); + let r = pump(&mut s).unwrap().unwrap(); + let mut t = one_doc(); + let plain = headless(&mut t, "alert('x'); 1", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap(); + assert_eq!((r["fuelUsed"].clone(), r["hostCalls"].clone()), (plain["fuelUsed"].clone(), plain["hostCalls"].clone()), "{r}"); +} + +#[test] +fn second_run_is_busy_while_dialog_waits() { + let mut s = one_doc(); + framed(&mut s, "alert('x');"); + assert!(s.execute("script.run", &json!({"source": "1"})).is_err()); + assert!(s.script_dialog().is_some(), "the first run still waits"); + assert!(s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 1, "height": 1})).is_err(), "edits are refused while it waits"); +} + +#[test] +fn a_dialog_in_a_callback_throws_in_the_app() { + let mut s = one_doc(); + s.script_frames = true; + let source = "var m = ''; [1].forEach(function () { try { alert('x'); } catch (e) { m = e.message; } }); vc.log(m);"; + s.execute("script.run", &json!({"source": source})).unwrap(); + let r = pump(&mut s).unwrap().unwrap(); + assert!(r["log"][0].as_str().unwrap().contains("callback"), "{r}"); + assert_eq!(r["dialogCount"], 0); +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 1399c0570..b5ed7419b 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -175,14 +175,13 @@ pub(crate) enum NativeKind { /// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section /// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given. -/// `Data` is a host function given [`crate::Data`]; with `text`, each argument arrives as the -/// string `String(value)` gives. +/// `Data` is a host function given [`crate::Data`], its arguments read as `read` says. #[derive(Clone)] 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, text: bool }, + Data { f: DataFn, name: Rc, read: crate::host::ArgRead }, Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 888fb3d95..475b4f6f2 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -57,11 +57,38 @@ pub enum HostError { Range(String), /// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it. Abort(String), + /// Pauses the run at this call: [`crate::Realm::advance`] returns [`crate::Progress::Paused`], + /// and the call's result is the value [`crate::Realm::answer`] gives it. A function may return + /// it only while [`crate::Realm::wait_flag`] is set; otherwise the run ends with an internal + /// error. + Wait, } /// A function the host gives a script, with its arguments converted. pub type HostFunction = Rc) -> Result>; +/// How one argument of a [`HostItem::Converted`] function is read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arg { + /// The string `String(value)` gives. + Text, + /// `undefined` as [`Data::Undefined`], any other value as the string `String(value)` gives. + TextOrUndefined, + /// The boolean `Boolean(value)` gives. + Bool, +} + +/// How the arguments of a host function are read. +#[derive(Clone)] +pub(crate) enum ArgRead { + /// Each as [`Data`]. + Data, + /// Each as the string `String(value)` gives. + Text, + /// One by one as the list says; those past its end are not read. + Each(Rc<[Arg]>), +} + /// A member of an object the host adds to the global environment. #[derive(Clone)] pub enum HostItem { @@ -70,6 +97,9 @@ pub enum HostItem { Function(HostFunction), /// A function whose arguments arrive as strings, each the string `String(value)` gives. TextFunction(HostFunction), + /// A function whose arguments arrive converted one by one as the list says. Arguments past the + /// end of the list are not read and do not reach the function. + Converted(Vec, HostFunction), /// A property whose value the function gives each time it is read. Getter(HostFunction), /// A frozen object with these members. @@ -277,20 +307,32 @@ impl Interp { Ok(Value::Object(id)) } - /// Calls host function `f`: reads its arguments (with `text`, each as the string `String(value)` - /// gives), runs it, and gives its result to the script. - pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value], text: bool) -> JsResult { + /// Calls host function `f`: reads its arguments as `how` says, runs it, and gives its result to + /// the script. + pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value], how: &ArgRead) -> JsResult { let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() }; + let args = match how { + ArgRead::Each(list) => args.get(..list.len().min(args.len())).unwrap_or(args), + _ => args, + }; let mut data = Vec::with_capacity(args.len()); // A conversion can run the script's own `toString`, which may collect garbage. let mark = self.temp_roots.len(); - if text { + if !matches!(how, ArgRead::Data) { self.temp_roots.extend(args.iter().cloned()); } let mut read = Ok(()); for (i, a) in args.iter().enumerate() { reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); - let d = if text { self.read_text(a, &mut reading) } else { self.read_data(a, &mut reading) }; + let d = match how { + ArgRead::Data => self.read_data(a, &mut reading), + ArgRead::Text => self.read_text(a, &mut reading), + ArgRead::Each(list) => match list.get(i) { + Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined), + Some(Arg::Bool) => Ok(Data::Bool(a.truthy())), + _ => self.read_text(a, &mut reading), + }, + }; match d { Ok(d) => data.push(d), Err(e) => { @@ -302,8 +344,19 @@ impl Interp { } self.temp_roots.truncate(mark); read?; - match f(data) { + // Only the script's own code, in a run that can pause, can wait for a result. + let can_wait = self.in_slice && self.reentry == 0; + self.can_wait.set(can_wait); + let result = f(data); + self.can_wait.set(false); + match result { Ok(d) => self.write_data(d, 0, false), + Err(HostError::Wait) if can_wait => { + self.waiting = true; + self.pause.set(true); + Ok(Value::Undefined) + } + Err(HostError::Wait) => Err(Abrupt::Internal(format!("{name} waited for its result where the run cannot pause"))), Err(HostError::Abort(message)) => { self.host_abort = Some(message); Err(Abrupt::Limit(LimitKind::Host)) @@ -357,17 +410,28 @@ impl Interp { 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, false)), flags: ENUMERABLE }, - HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, true)), flags: ENUMERABLE }, - HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, false)), set: None, flags: ENUMERABLE }, + HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE }, HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, + HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Data)), flags: ENUMERABLE }, + HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Text)), flags: ENUMERABLE }, + HostItem::Converted(list, f) => { + Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Each(list.into()))), flags: ENUMERABLE } + } }; self.define(o, key(&name), prop) } - fn host_function(&mut self, f: HostFunction, name: &str, text: bool) -> ObjId { + /// Adds the global function `name` with its arguments converted as `args` says. A script may + /// replace or delete it, as it may a built-in function. + pub(crate) fn install_host_function(&mut self, name: &str, args: Vec, f: HostFunction) -> JsResult<()> { + let o = self.host_function(f, name, ArgRead::Each(args.into())); + let global = self.global; + self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: crate::heap::HIDDEN }) + } + + fn host_function(&mut self, f: HostFunction, name: &str, read: ArgRead) -> ObjId { let proto = self.intrinsics.function_proto; - self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into(), text }), Some(proto))) + self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into(), read }), Some(proto))) } } diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 93875f6d1..3492cf653 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -277,6 +277,84 @@ fn advancing_without_a_run_is_an_error() { assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "no run to advance")); } +/// A realm with the global `ask(question)`, which waits for its answer where it can and throws an +/// `Error` where it cannot. → how many times it was called. +fn with_ask(r: &mut Realm) -> Rc> { + let calls: Rc> = Rc::default(); + let (count, can_wait) = (calls.clone(), r.wait_flag()); + let f: HostFunction = Rc::new(move |_| { + count.set(count.get() + 1); + if can_wait.get() { Err(HostError::Wait) } else { Err(HostError::Error("cannot wait here".into())) } + }); + r.define_host_function("ask", vec![Arg::Text], f).unwrap(); + calls +} + +#[test] +fn a_waiting_call_pauses_at_the_call_and_takes_its_answer_once() { + let mut r = realm(1_000_000); + let calls = with_ask(&mut r); + r.start("var a = ask('first'); var b = ask('second'); a + '|' + b").unwrap(); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits()); + let fuel = r.fuel_left(); + let e = r.advance(1_000).unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "the run waits for a host function's result")); + assert_eq!(r.fuel_left(), fuel, "no fuel is spent while the call waits"); + r.answer(Data::String("one".into())).unwrap(); + assert!(!r.waits()); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits()); + r.answer(Data::Null).unwrap(); + assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("one|null".into())); + assert_eq!(calls.get(), 2, "each call ran once"); + let e = r.answer(Data::Null).unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Internal); +} + +#[test] +fn only_the_scripts_own_code_in_a_resumable_run_can_wait() { + let mut r = realm(1_000_000); + with_ask(&mut r); + let caught = "var out = []; [1].forEach(function () { try { ask('x'); } catch (e) { out.push(e.message); } }); \ + var o = {}; Object.defineProperty(o, 'g', {get: function () { try { ask('y'); } catch (e) { out.push(e.message); } }}); o.g; \ + try { String({toString: function () { return ask('z'); }}); } catch (e) { out.push(e.message); } out.join()"; + r.start(caught).unwrap(); + assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("cannot wait here,cannot wait here,cannot wait here".into())); + let e = r.run("ask('q')").unwrap_err(); + assert!(e.message.contains("cannot wait here"), "a run that cannot pause cannot wait: {}", e.message); + r.start("var f = ask; ask.call(null, 'q'); f.apply(null, ['r'])").unwrap(); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert!(r.waits(), "call and apply from the script's own code wait"); +} + +#[test] +fn a_host_function_that_waits_where_it_cannot_ends_the_run() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Err(HostError::Wait)); + r.define_host_function("ask", vec![], f).unwrap(); + let e = r.run("try { ask(); } catch (e) { 'caught' }").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Internal); +} + +#[test] +fn a_converted_function_reads_each_argument_as_its_list_says() { + let mut r = realm(1_000_000); + let seen: Rc>>> = Rc::default(); + let log = seen.clone(); + let f: HostFunction = Rc::new(move |args| { + log.borrow_mut().push(args); + Ok(Data::Undefined) + }); + r.define_host_function("conv", vec![Arg::Text, Arg::TextOrUndefined, Arg::Bool], f).unwrap(); + r.run("conv(42, undefined, 'x', function () {}); conv(undefined, null, 0); conv({toString: function () { return 'own'; }}); conv()").unwrap(); + let s = |t: &str| Data::String(t.into()); + let want = vec![vec![s("42"), Data::Undefined, Data::Bool(true)], vec![s("undefined"), s("null"), Data::Bool(false)], vec![s("own")], vec![]]; + assert_eq!(*seen.borrow(), want, "arguments past the list are not read"); + let out = r.run("var a = typeof conv; conv = 1; a + ' ' + conv").unwrap(); + assert_eq!(out, JsValue::String("function 1".into()), "a script may replace the function"); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index ee711ffb3..4fd4a038a 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -181,6 +181,12 @@ pub(crate) struct Interp { 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 outermost loop of a resumable run is running ([`crate::Realm::advance`]). + pub(crate) in_slice: bool, + /// True while a host function runs where it may wait ([`crate::HostError::Wait`]). + pub(crate) can_wait: Rc>, + /// A host function's call waits for its result: the value on top of the stack stands in for it. + pub(crate) waiting: bool, /// 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. @@ -374,6 +380,9 @@ impl Interp { date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), pause: Rc::default(), + in_slice: false, + can_wait: Rc::default(), + waiting: false, host_abort: None, data_limits: crate::DataLimits::default(), }; @@ -414,7 +423,7 @@ impl Interp { // ---- the operand stack ---- - fn push(&mut self, v: Value) -> JsResult<()> { + pub(crate) fn push(&mut self, v: Value) -> JsResult<()> { self.stack.push(v); Ok(()) } @@ -1846,10 +1855,10 @@ impl Interp { self.temp_roots.truncate(mark); return Ok(Prepared::Done(r?)); } - Func::Data { f, name, text } => { + Func::Data { f, name, read } => { // 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, text)?)); + return Ok(Prepared::Done(self.call_data(&f, &name, &args, &read)?)); } Func::Host { f: host, .. } => { let js_args: Vec = args.iter().map(|v| self.to_js_value(v)).collect(); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index a95a4b884..a92de47b3 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -66,7 +66,7 @@ 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 host::{Arg, 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, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind}; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index a28b150a5..9e2e95b4a 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -218,6 +218,7 @@ impl Realm { interp.throw_site = None; interp.last_thrown = Value::Undefined; interp.host_abort = None; + interp.waiting = false; let strict = interp.options.strict; match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) { Ok(code) => Ok(code), @@ -234,6 +235,7 @@ impl Realm { interp.frames.clear(); interp.temp_roots.clear(); interp.join_stack.clear(); + interp.waiting = false; match result { Ok(v) => Ok(interp.to_js_value(&v)), Err(Abrupt::Throw(v)) => { @@ -263,16 +265,22 @@ impl Realm { /// 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. + /// [`Progress::Done`] or an error, the run is over. While a host function's call waits for its + /// result ([`Realm::waits`]), the run does not advance: this gives an internal error. 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 }); } + if interp.waiting { + return Err(RunError { kind: RunErrorKind::Internal, message: "the run waits for a host function's result".into(), line: 0, column: 0 }); + } let total = interp.fuel; let cap = slice.saturating_add(self.unbroken_fuel).min(total); interp.fuel = cap; + interp.in_slice = true; let r = interp.execute_slice(cap - slice.min(cap)); + interp.in_slice = false; let used = cap - interp.fuel; interp.fuel = total - used; match r { @@ -294,6 +302,38 @@ impl Realm { self.interp.pause.clone() } + /// The flag that is set while a host function runs where it may return + /// [`crate::HostError::Wait`]: called by the script's own code in a run [`Realm::advance`] + /// carries on, and not from a getter, a conversion or a built-in's callback. + pub fn wait_flag(&self) -> std::rc::Rc> { + self.interp.can_wait.clone() + } + + /// Does a host function's call wait for the result [`Realm::answer`] gives it? + pub fn waits(&self) -> bool { + self.interp.waiting + } + + /// Gives the call that waits ([`crate::HostError::Wait`]) its result `d`; the next + /// [`Realm::advance`] goes on from there. A result past the heap limit ends the run with that + /// limit, and with no call waiting this gives an internal error. + pub fn answer(&mut self, d: crate::Data) -> Result<(), RunError> { + let interp = &mut self.interp; + if !interp.waiting { + return Err(RunError { kind: RunErrorKind::Internal, message: "no host function waits for a result".into(), line: 0, column: 0 }); + } + interp.waiting = false; + // The value on top of the stack stood in for the result. + let r = interp.write_data(d, 0, false).and_then(|v| { + interp.stack.pop(); + interp.push(v) + }); + match r { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + /// 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(); @@ -319,6 +359,16 @@ impl Realm { } } + /// Defines the global function `name`, whose arguments arrive converted as `args` says + /// ([`crate::HostItem::Converted`]). Unlike a host object, a script may replace or delete it, as + /// it may a built-in function. + pub fn define_host_function(&mut self, name: &str, args: Vec, f: crate::HostFunction) -> Result<(), RunError> { + match self.interp.install_host_function(name, args, f) { + 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/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 80614f909..a0fc52405 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -341,7 +341,7 @@ impl VectorcraftApp { screen_color::poll(self, ctx); self.poll_finished_work(); crate::script_runs::poll(self); - if self.session.script_running() && !self.session.script_waiting() { + if self.session.script_running() && !self.session.script_waiting() && self.session.script_dialog().is_none() { ctx.request_repaint(); } #[cfg(not(target_arch = "wasm32"))] @@ -370,9 +370,7 @@ impl VectorcraftApp { unsaved::finish_pending(self); 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!({}))); - } + dialogs::script_run::ask_to_close(self); } else if self.file_sheet_open() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.status(picks::busy()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index edea838ec..c13a230a9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -410,6 +410,7 @@ registry! { 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, + ScriptNotice: [script_run::NOTICE_KIND] => script_run::NOTICE_SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, @@ -588,6 +589,9 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == script_run::GROWTH_KIND { app.session.script_request_stop(); } + if d.kind == script_run::NOTICE_KIND { + script_run::notice_cancel(app, &d); + } // 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 index 2ef43cb8f..eb89bd174 100644 --- a/crates/ui-egui/src/dialogs/script_run.rs +++ b/crates/ui-egui/src/dialogs/script_run.rs @@ -1,16 +1,25 @@ //! 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. +//! (`script.run` runs it), the question a run that keeps adding art asks, the question quitting +//! asks while a script runs, and the dialogs a script opens with `alert`, `confirm` and `prompt`. //! //! `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). +//! +//! A script's dialog (kind `scriptDialog`) has the fields `kind` (alert, confirm or prompt), +//! `message`, `title` (null: Script Alert), `errorIcon`, `noAsDefault` and `value` (the prompt's +//! text), and takes `answer`: `ok` (alert, prompt), `yes` or `no` (confirm), `cancel` (prompt) or +//! `stop`. OK, Enter or `ui.dialog.confirm` without an `answer` gives the default button's answer. +//! Escape or `ui.dialog.cancel` gives Cancel for a prompt, No for a confirm and Stop for an alert. +//! Stop ends the run and puts back everything it did. use serde_json::{Value, json}; use vectorcraft_engine::cmd::scripts::{RUN_EXTS, TOO_LARGE, runs}; +use vectorcraft_engine::script::dialogs::{MAX_PROMPT_BYTES, cut_to, shown_text}; +use vectorcraft_engine::script::{DialogAnswer, ScriptDialog}; -use super::DialogSpec; +use super::{DialogAction, DialogSpec}; use crate::state::Dialog; use crate::theme::{self, Tokens}; use crate::{FilePick, VectorcraftApp, widgets}; @@ -21,6 +30,19 @@ pub const KIND: &str = "scriptRun"; pub const GROWTH_KIND: &str = "scriptGrowth"; /// The dialog kind of the question quitting asks while a script runs. pub const CLOSE_KIND: &str = "scriptClose"; +/// The dialog kind of a script's `alert`, `confirm` and `prompt`. +pub const NOTICE_KIND: &str = "scriptDialog"; + +pub(super) const NOTICE_SPEC: DialogSpec = DialogSpec { + heading: notice_heading, + body_with_actions: Some(notice_body), + confirm: notice_confirm, + ok: None, + inputs: |_| &["answer"], + min_width: 360.0, + max_width: Some(560.0), + ..DialogSpec::FORM +}; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Run Script").into(), @@ -145,3 +167,154 @@ fn close_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result } Ok(Value::Null) } + +/// Quitting while a script runs asks whether to stop it, also over the script's own dialog, which +/// Cancel shows again as it was. Any other open dialog stays, and quitting asks nothing yet. +pub(crate) fn ask_to_close(app: &mut VectorcraftApp) { + let notice = app.ui.dialog.take_if(|d| d.kind == NOTICE_KIND); + if app.ui.dialog.is_some() { + return; + } + let mut fields = json!({}); + if let Some(notice) = notice { + fields[super::PARENT] = serde_json::to_value(notice).unwrap_or_default(); + } + app.ui.dialog = Some(Dialog::new(CLOSE_KIND, fields)); +} + +/// Shows the dialog the running script waits at. +pub(crate) fn open_notice(app: &mut VectorcraftApp, d: &ScriptDialog) { + let fields = json!({ + "kind": d.kind.id(), + "message": d.message, + "title": d.title, + "errorIcon": d.error_icon, + "noAsDefault": d.no_as_default, + "value": d.preset, + }); + app.ui.dialog = Some(Dialog::new(NOTICE_KIND, fields)); +} + +/// The dialog's heading: the script's title on one line, or Script Alert. +pub(crate) fn notice_heading(d: &Dialog) -> String { + match d.fields.get("title").and_then(Value::as_str) { + Some(title) => shown_text(title, false), + None => tl!("Script Alert").into(), + } +} + +/// The answer of the default button: OK, or for a confirm Yes, or No with `noAsDefault`. +fn default_answer(d: &Dialog) -> &'static str { + match (d.str("kind").as_str(), d.bool("noAsDefault")) { + ("confirm", true) => "no", + ("confirm", false) => "yes", + _ => "ok", + } +} + +/// A button that is the primary one when it is the default. +fn answer_button(ui: &mut egui::Ui, label: &str, default: bool) -> bool { + if default { widgets::primary_button_enabled(ui, label, true).clicked() } else { widgets::secondary_button(ui, label).clicked() } +} + +fn notice_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction { + let t = Tokens::get(ui.ctx()); + let kind = d.str("kind"); + let message = shown_text(&d.str("message"), true); + ui.horizontal_top(|ui| { + if d.bool("errorIcon") { + ui.label(egui::RichText::new("!").font(theme::semibold(22.0)).color(t.error)); + ui.add_space(8.0); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| ui.label(&message)); + }); + if kind == "prompt" { + ui.add_space(8.0); + let name = cut_to(&shown_text(&message, false), 200).to_string(); + super::form::text_edit_named(ui, d, "value", &name, ui.available_width()); + let value = d.str("value"); + if value.len() > MAX_PROMPT_BYTES { + d.fields.insert("value".into(), json!(cut_to(&value, MAX_PROMPT_BYTES))); + } + } + ui.add_space(16.0); + let default = default_answer(d); + let mut answer = None; + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { + let buttons: &[(&str, &str)] = match kind.as_str() { + "confirm" => &[("yes", "Yes"), ("no", "No")], + "prompt" => &[("ok", "OK"), ("cancel", "Cancel")], + _ => &[("ok", "OK")], + }; + for (id, label) in buttons { + if answer_button(ui, tl!(label), *id == default) { + answer = Some(*id); + } + ui.add_space(8.0); + } + // The row runs right to left: Stop goes at its left end. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if widgets::secondary_button(ui, tl!("Stop")).clicked() { + answer = Some("stop"); + } + }); + }); + if answer.is_none() && crate::shortcuts::pressed_anew(ui.ctx(), egui::Key::Enter) { + answer = Some(default); + } + let Some(answer) = answer else { return DialogAction::None }; + d.fields.insert("answer".into(), json!(answer)); + DialogAction::Confirm +} + +/// Gives the running script the dialog's `answer`, or the default button's. +fn notice_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let answer = d.fields.get("answer").and_then(Value::as_str).unwrap_or(default_answer(d)); + give(app, d, answer) +} + +/// Escape or Cancel: Cancel for a prompt, No for a confirm and Stop for an alert. +pub(super) fn notice_cancel(app: &mut VectorcraftApp, d: &Dialog) { + let answer = match d.str("kind").as_str() { + "prompt" => "cancel", + "confirm" => "no", + _ => "stop", + }; + let _ = give(app, d, answer); +} + +fn give(app: &mut VectorcraftApp, d: &Dialog, answer: &str) -> Result { + let given = match (d.str("kind").as_str(), answer) { + (_, "stop") => None, + ("alert", "ok") => Some(DialogAnswer::Ok), + ("confirm", "yes") => Some(DialogAnswer::Confirm(true)), + ("confirm", "no") => Some(DialogAnswer::Confirm(false)), + ("prompt", "ok") => Some(DialogAnswer::Prompt(Some(d.str("value")))), + ("prompt", "cancel") => Some(DialogAnswer::Prompt(None)), + (kind, _) => return Err(format!("`answer` for this {kind} dialog must be one of: {}", answers_for(kind))), + }; + match given { + None => { + app.session.script_request_stop(); + } + Some(a) => { + // Refused while the dialog still waits: the text is too long, and the dialog stays open. + if !app.session.script_answer(a) && app.session.script_dialog().is_some() { + return Err(format!("the prompt's text is longer than {MAX_PROMPT_BYTES} bytes")); + } + } + } + if app.ui.dialog.as_ref().is_some_and(|open| open.kind == NOTICE_KIND) { + app.ui.dialog = None; + } + Ok(Value::Null) +} + +fn answers_for(kind: &str) -> &'static str { + match kind { + "confirm" => "yes, no, stop", + "prompt" => "ok, cancel, stop", + _ => "ok, stop", + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ee32cee33..44399fad1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3976,6 +3976,7 @@ axis Both И двете Stop Script Спри скрипта Show External Script Warning Показвай предупреждение за външни скриптове A Script Is Running Изпълнява се скрипт + Script Alert Известие от скрипт 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. Да се спре ли скриптът и да се отменят ли промените му? След това излезте отново. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 0970a7092..6ffc45297 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4341,6 +4341,7 @@ imageTrace Outline Obris Stop Script Zaustavi skriptu Show External Script Warning Prikaži upozorenje za vanjske skripte A Script Is Running Skripta se izvršava + Script Alert Upozorenje skripte 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. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 84b6833c4..849c82a78 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5188,6 +5188,7 @@ digits Malayalam Malaiàlam 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 + Script Alert Avís de l'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. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 733a3e813..d4d311216 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4399,6 +4399,7 @@ imageTrace Outline Obrys Stop Script Zastavit skript Show External Script Warning Zobrazit upozornění u externích skriptů A Script Is Running Běží skript + Script Alert Upozornění skriptu 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. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index d1871b2ed..2da5515c5 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4095,6 +4095,7 @@ imageTrace Outline Kontur Stop Script Stop script Show External Script Warning Vis advarsel om eksterne scripts A Script Is Running Et script kører + Script Alert Scriptadvarsel 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. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8c75b25b6..ce5663031 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4005,6 +4005,7 @@ theme Appearance Erscheinungsbild Stop Script Skript stoppen Show External Script Warning Warnung bei externen Skripten anzeigen A Script Is Running Ein Skript wird ausgeführt + Script Alert Skriptmeldung 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. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7813ee6cf..008671206 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2245,6 +2245,7 @@ axis Both Και τα δύο Stop Script Διακοπή σεναρίου Show External Script Warning Εμφάνιση προειδοποίησης για εξωτερικά σενάρια A Script Is Running Εκτελείται ένα σενάριο + Script Alert Ειδοποίηση σεναρίου 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. Διακοπή του σεναρίου και αναίρεση όσων έκανε; Έπειτα κάντε ξανά έξοδο. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d8bcecbc6..3853617ca 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4089,6 +4089,7 @@ axis Both Ambos Stop Script Detener script Show External Script Warning Mostrar advertencia de scripts externos A Script Is Running Se está ejecutando un script + Script Alert Aviso del 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. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d3b9fb326..2c821f843 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4313,6 +4313,7 @@ imageTrace Outline Ääriviiva Stop Script Pysäytä komentosarja Show External Script Warning Näytä ulkoisten komentosarjojen varoitus A Script Is Running Komentosarja on käynnissä + Script Alert Komentosarjan ilmoitus 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. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6db506204..59e8642e3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4078,6 +4078,7 @@ axis Both Les deux 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 + Script Alert Alerte de script 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. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3fa226908..92c7d6ad1 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6178,6 +6178,7 @@ antiAlias Sharp शार्प Stop Script स्क्रिप्ट रोकें Show External Script Warning बाहरी स्क्रिप्ट की चेतावनी दिखाएँ A Script Is Running एक स्क्रिप्ट चल रही है + Script Alert स्क्रिप्ट सूचना 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. स्क्रिप्ट रोककर उसके बदलाव पूर्ववत करें? फिर दोबारा बाहर निकलें। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27e22d0ad..9bb1ae49e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4886,6 +4886,7 @@ imageTrace Outline Körvonal 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 + Script Alert Parancsfájl-értesítés 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. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c4e97482a..7225ff3e9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4095,6 +4095,7 @@ imageTrace Outline Kontur Stop Script Hentikan Skrip Show External Script Warning Tampilkan Peringatan Skrip Eksternal A Script Is Running Skrip Sedang Berjalan + Script Alert Peringatan Skrip 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. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 987505a43..a697c5422 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4132,6 +4132,7 @@ axis Both Entrambi Stop Script Interrompi script Show External Script Warning Mostra avviso per gli script esterni A Script Is Running È in esecuzione uno script + Script Alert Avviso dello 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. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d3c0b6603..1ccd7b55a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4862,6 +4862,7 @@ swatch Bright Violet ビビッドバイオレット Stop Script スクリプトを停止 Show External Script Warning 外部スクリプトの警告を表示 A Script Is Running スクリプトの実行中 + Script Alert スクリプトの通知 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. スクリプトを停止して変更を取り消しますか?その後、もう一度終了してください。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5a05ccec3..cf0ce6f35 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5177,6 +5177,7 @@ imageTrace Outline კონტური Stop Script სკრიპტის შეჩერება Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება A Script Is Running სკრიპტი მუშაობს + Script Alert სკრიპტის შეტყობინება 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. შეჩერდეს სკრიპტი და გაუქმდეს მისი ცვლილებები? შემდეგ ხელახლა გამოდით. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b0fc26d1f..51dd2528d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4789,6 +4789,7 @@ theme Appearance 외관 Stop Script 스크립트 중지 Show External Script Warning 외부 스크립트 경고 표시 A Script Is Running 스크립트 실행 중 + Script Alert 스크립트 알림 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. 스크립트를 중지하고 변경 내용을 취소할까요? 그런 다음 다시 종료하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c30a50e71..d604b6165 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4109,6 +4109,7 @@ imageTrace Outline Contour Stop Script Script stoppen Show External Script Warning Waarschuwing voor externe scripts tonen A Script Is Running Er wordt een script uitgevoerd + Script Alert Scriptmelding 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. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8450cb654..5980dfccf 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4222,6 +4222,7 @@ imageTrace Outline Kontur Stop Script Zatrzymaj skrypt Show External Script Warning Pokazuj ostrzeżenie o skryptach zewnętrznych A Script Is Running Skrypt jest uruchomiony + Script Alert Komunikat skryptu 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. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 00d9a8948..ce0dfcfec 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5066,6 +5066,7 @@ axis Both Ambos Stop Script Parar script Show External Script Warning Mostrar aviso de scripts externos A Script Is Running Um script está em execução + Script Alert Alerta do script 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. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 24a6c74d5..3963b9e51 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4126,6 +4126,7 @@ axis Both Оба Stop Script Остановить скрипт Show External Script Warning Показывать предупреждение о внешних скриптах A Script Is Running Выполняется скрипт + Script Alert Сообщение скрипта 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. Остановить скрипт и отменить его изменения? Затем выйдите ещё раз. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c038612c1..e5603a14c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3999,6 +3999,7 @@ axis Both Båda Stop Script Stoppa skript Show External Script Warning Visa varning för externa skript A Script Is Running Ett skript körs + Script Alert Skriptmeddelande 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. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 282990771..57517c4a3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3881,6 +3881,7 @@ axis Both Her İkisi Stop Script Betiği Durdur Show External Script Warning Harici Betik Uyarısını Göster A Script Is Running Bir Betik Çalışıyor + Script Alert Betik Uyarısı 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. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 88bcb4702..ccaba51b6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4116,6 +4116,7 @@ axis Both Обидві Stop Script Зупинити скрипт Show External Script Warning Показувати попередження про зовнішні скрипти A Script Is Running Виконується скрипт + Script Alert Повідомлення скрипту 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. Зупинити скрипт і скасувати його зміни? Потім вийдіть ще раз. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 90c292bc9..7f00d9db5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4535,6 +4535,7 @@ theme Appearance Giao diện 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 + Script Alert Thông báo của tập lệnh 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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 77578eec1..7467c6126 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4569,6 +4569,7 @@ imageTrace Outline 轮廓 Stop Script 停止脚本 Show External Script Warning 显示外部脚本警告 A Script Is Running 脚本正在运行 + Script Alert 脚本提示 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. 要停止脚本并撤销其更改吗?然后请再次退出。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bdec63590..650984daa 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2397,6 +2397,7 @@ file-filter Templates 範本 Stop Script 停止指令碼 Show External Script Warning 顯示外部指令碼警告 A Script Is Running 指令碼正在執行 + Script Alert 指令碼提示 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. 要停止指令碼並還原其變更嗎?然後請再次結束。 diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs index 042e24cd3..65a9dc9eb 100644 --- a/crates/ui-egui/src/script_runs.rs +++ b/crates/ui-egui/src/script_runs.rs @@ -1,6 +1,7 @@ //! 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. +//! shows it with a Stop button; a run that has added a great deal, or that opened a dialog with +//! `alert`, `confirm` or `prompt`, waits for the person's answer. use serde_json::json; @@ -14,15 +15,16 @@ pub fn poll(app: &mut VectorcraftApp) { 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!({}))); - } + if ask(app) { 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 }; + let Some(result) = app.session.script_pump() else { + // The app draws no more frames while the run waits: its question opens in this one. + ask(app); + return; + }; if cursor_settings != (app.session.caps_lock, app.session.prefs.use_precise_cursors) { app.canvas.hover_cursor = None; } @@ -36,6 +38,29 @@ pub fn poll(app: &mut VectorcraftApp) { app.sync_views(); } +/// Opens what the run waits at, once no other dialog is open: the script's own dialog, or the +/// question about its growth. Closes the script's dialog when the run no longer waits at it. +/// → true while the run waits. +fn ask(app: &mut VectorcraftApp) -> bool { + if let Some(dialog) = app.session.script_dialog() { + if app.ui.dialog.is_none() { + script_run::open_notice(app, &dialog); + } + return true; + } + // A run asked to stop no longer waits at its dialog. + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::NOTICE_KIND) { + app.ui.dialog = None; + } + if app.session.script_waiting() { + if app.ui.dialog.is_none() { + app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({}))); + } + return true; + } + false +} + /// 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) { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 261a25054..bc4251ea6 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_dialogs; mod script_run; mod scrub; mod selectall; diff --git a/crates/ui-egui/src/tests/script_dialogs.rs b/crates/ui-egui/src/tests/script_dialogs.rs new file mode 100644 index 000000000..da09c777b --- /dev/null +++ b/crates/ui-egui/src/tests/script_dialogs.rs @@ -0,0 +1,256 @@ +//! A script's `alert`, `confirm` and `prompt` in the app: the dialog the run waits at, its buttons, +//! Enter and Escape, Stop, another dialog in the way, quitting while it waits, and the same answers +//! given by a run with nobody to ask. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use crate::dialogs::script_run::{CLOSE_KIND, NOTICE_KIND}; +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.session.prefs.show_external_script_warning = false; + app +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10});"; + +fn start(app: &mut VectorcraftApp, source: &str) { + menus::run_ui_command(app, "ui.runScript", &json!({"source": source, "name": "d.js"})).unwrap().unwrap(); +} + +/// Polls the run until it ends, or until its dialog is open (true). +fn until_dialog(app: &mut VectorcraftApp) -> bool { + for _ in 0..10_000 { + if !app.session.script_running() { + return false; + } + script_runs::poll(app); + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == NOTICE_KIND) { + return true; + } + } + panic!("the script neither ended nor opened a dialog"); +} + +fn answer(app: &mut VectorcraftApp, answer: &str) { + app.ui.dialog.as_mut().unwrap().fields.insert("answer".into(), json!(answer)); + dialogs::confirm(app).unwrap(); +} + +thread_local! { + // One context per test, so frames follow one another as in the app. + static CTX: egui::Context = { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + }; +} + +/// One headless frame of the dialog layer and the shortcuts, in a window of `size`. Each key pressed +/// in it is let go in a second frame, as a person lets go of it. +fn frame(app: &mut VectorcraftApp, events: Vec, size: egui::Vec2) { + let ctx = CTX.with(Clone::clone); + let released: Vec = events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, physical_key, modifiers, pressed: true, .. } => { + Some(egui::Event::Key { key: *key, physical_key: *physical_key, pressed: false, repeat: false, modifiers: *modifiers }) + } + _ => None, + }) + .collect(); + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)); + let pressed = !released.is_empty(); + for events in std::iter::once(events).chain(pressed.then_some(released)) { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + dialogs::show(app, ui.ctx()); + }); + out.textures_delta.clear(); + } +} + +fn key(k: egui::Key) -> egui::Event { + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() } +} + +const WINDOW: egui::Vec2 = egui::vec2(1200.0, 800.0); + +fn log(app: &VectorcraftApp) -> Value { + app.session.script_report().unwrap()["log"].clone() +} + +#[test] +fn alert_waits_and_stop_rolls_back_first_dialog() { + for stop_with_escape in [false, true] { + let mut app = app(); + start(&mut app, &format!("{DRAW} while (1) {{ alert('x'); }}")); + assert!(until_dialog(&mut app)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["kind"].as_str(), d.fields["message"].as_str(), d.fields["title"].clone()), (Some("alert"), Some("x"), Value::Null)); + for _ in 0..5 { + script_runs::poll(&mut app); + } + assert!(app.session.script_running(), "the run waits at the dialog"); + assert_eq!(objects(&app), 0); + if stop_with_escape { + frame(&mut app, vec![key(egui::Key::Escape)], WINDOW); + } else { + answer(&mut app, "stop"); + } + assert!(app.ui.dialog.is_none()); + assert!(!until_dialog(&mut app)); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); + assert_eq!(app.session.script_report().unwrap()["dialogs"][0]["answer"], "stop"); + } +} + +#[test] +fn confirm_enter_uses_no_as_default() { + for (no_as_default, want) in [(true, false), (false, true)] { + let mut app = app(); + start(&mut app, &format!("vc.log(confirm('Go?', {no_as_default}));")); + assert!(until_dialog(&mut app)); + frame(&mut app, vec![], WINDOW); + frame(&mut app, vec![key(egui::Key::Enter)], WINDOW); + assert!(app.ui.dialog.is_none(), "Enter answers the dialog: noAsDflt {no_as_default}"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([want]), "noAsDflt {no_as_default}"); + } + let mut app = app(); + start(&mut app, "vc.log(confirm('Go?'));"); + assert!(until_dialog(&mut app)); + frame(&mut app, vec![key(egui::Key::Escape)], WINDOW); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([false]), "Escape is No"); +} + +#[test] +fn prompt_ok_and_cancel_return_values() { + let mut app = app(); + start(&mut app, "vc.log(prompt('Name', 'pre', 'Ask')); vc.log(prompt('Again', 'x'));"); + assert!(until_dialog(&mut app)); + assert_eq!(dialogs::script_run::notice_heading(app.ui.dialog.as_ref().unwrap()), "Ask"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["value"], "pre"); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("typed")); + dialogs::confirm(&mut app).unwrap(); + assert!(until_dialog(&mut app)); + dialogs::cancel(&mut app); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!(["typed", null])); + let mut app = self::app(); + start(&mut app, "vc.log(prompt('Name', ''));"); + assert!(until_dialog(&mut app)); + let e = { + app.ui.dialog.as_mut().unwrap().fields.insert("answer".into(), json!("yes")); + dialogs::confirm(&mut app).unwrap_err() + }; + assert!(e.contains("ok, cancel, stop"), "{e}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND, "a wrong answer leaves the dialog open"); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!([""]), "OK with an empty field gives an empty string"); +} + +#[test] +fn the_dialog_opens_in_the_frame_the_run_waits() { + let mut app = app(); + start(&mut app, "alert('x');"); + script_runs::poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(NOTICE_KIND), "no later frame is drawn while the run waits"); +} + +#[test] +fn a_prompt_text_past_the_limit_keeps_the_dialog_open() { + let mut app = app(); + start(&mut app, "vc.log(prompt('Name', ''));"); + assert!(until_dialog(&mut app)); + let long = "x".repeat(vectorcraft_engine::script::dialogs::MAX_PROMPT_BYTES + 1); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!(long)); + assert!(dialogs::confirm(&mut app).is_err()); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND, "the refused answer leaves the dialog open"); + assert!(app.session.script_dialog().is_some(), "the run still waits"); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("ok")); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(log(&app), json!(["ok"])); +} + +#[test] +fn dialog_wait_survives_another_modal() { + let mut app = app(); + start(&mut app, "alert('x');"); + script_runs::poll(&mut app); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::GROWTH_KIND, json!({}))); + let other = app.ui.dialog.clone(); + for _ in 0..3 { + script_runs::poll(&mut app); + } + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.clone()), other.map(|d| d.kind), "the open dialog stays"); + assert!(app.session.script_running()); + app.ui.dialog = None; + assert!(until_dialog(&mut app), "the script's dialog opens once the other one closes"); +} + +#[test] +fn close_while_notification_waits_abandons_or_resumes() { + let mut app = app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("half")); + dialogs::script_run::ask_to_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND); + script_runs::poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND, "the run goes on waiting"); + dialogs::cancel(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["value"].as_str()), (NOTICE_KIND, Some("half")), "Cancel shows the dialog again as it was"); + dialogs::script_run::ask_to_close(&mut app); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.script_running()); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn notification_layout_with_long_text_and_small_window() { + let mut app = app(); + let long = "word ".repeat(3_000); + start(&mut app, &format!("alert({long:?} + '\\u0007\\r\\n', 'T\\nitle', true);")); + assert!(until_dialog(&mut app)); + for _ in 0..3 { + frame(&mut app, vec![], egui::vec2(320.0, 240.0)); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND); + assert_eq!(dialogs::script_run::notice_heading(app.ui.dialog.as_ref().unwrap()), "T\u{240a}itle"); + answer(&mut app, "ok"); + assert!(!until_dialog(&mut app)); + assert_eq!(app.session.script_report().unwrap()["dialogs"][0]["errorIcon"], true); +} + +#[test] +fn the_app_and_a_run_with_nobody_to_ask_give_the_same_results() { + let source = "vc.log(confirm('a', true, 'T')); vc.log(prompt('b', 'p')); alert('c', undefined, false); vc.log('done');"; + let mut app = app(); + start(&mut app, source); + for a in ["yes", "ok", "ok"] { + assert!(until_dialog(&mut app)); + answer(&mut app, a); + } + assert!(!until_dialog(&mut app)); + let framed = app.session.script_report().unwrap().clone(); + let mut s = Session::new(); + let given = json!([{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "p"}, {"kind": "alert", "value": "ok"}]); + let headless = s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": given})).unwrap(); + assert_eq!(framed["log"], headless["log"]); + assert_eq!(framed["dialogs"], headless["dialogs"]); + assert_eq!(framed["log"], json!([true, "p", "done"])); +} diff --git a/docs/mcp.md b/docs/mcp.md index 069e13be4..596d0270f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1561,6 +1561,8 @@ app through the global object `vc`, and writes to the Script Console through `$` - `vc.log(value)` adds a value to the report and a line to the Script Console. - `$.write(...values)` writes the values to the Script Console with no separator, each as `String(value)` gives it, and `$.writeln(...values)` does the same and ends the line. Each call counts as a host call. +- `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and `prompt(message, preset[, title])` + open a dialog, described below. `vc.api.has("dialogs")` is true. - `vc.args` is the `args` object. - `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature. @@ -1600,15 +1602,17 @@ VectorCraft sets these limits itself: | `args` as JSON | 1 MiB | | Log | 10,000 entries and 4 MiB | | Script Console | 2,048 lines and 1 MiB of text; 4,096 bytes a line; 256 bytes of a script's name | +| A dialog's message, title and prompt text | 16 KiB, 256 bytes and 16 KiB | +| `answers` | 10,000 answers and 1 MiB as JSON | +| `dialogs` in the report | 10,000 dialogs and 1 MiB as JSON | | 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, -runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console}`. In the app the reply is `{running: true, -name}`, and the report appears later as `scriptReport` in `ui.inspect`. The report of a failed run has the same -fields after `kind`, `message`, `line`, `column` and `fuelLeft`. `answers` takes only an empty object, since scripts -have no prompts yet. +runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console, dialogs, dialogCount, dialogsTruncated}`. In +the app the reply is `{running: true, name}`, and the report appears later as `scriptReport` in `ui.inspect`. The +report of a failed run has the same fields after `kind`, `message`, `line`, `column` and `fuelLeft`. - `runTimeMs` is the time the script ran, its commands included. Waits between frames and for a person's answer are left out. `timingAvailable` is false on the web, where `runTimeMs` is 0. @@ -1616,6 +1620,11 @@ have no prompts yet. - `slowestCommands` lists at most 5 commands as `{id, calls, totalMs, maxMs}`, the longest in all first, then by id. - `console` is `{lines, droppedLines, truncatedLines}`: the run's own output, `vc.log` lines and uncaught error, as text. +- `dialogs` lists the run's dialogs in the order they opened, as `{kind, message, title, errorIcon, answer}` for + `alert`, `{kind, message, title, noAsDefault, answer}` for `confirm` and `{kind, message, title, preset, answer}` + for `prompt`. `answer` is `"ok"`, `true`, `false`, the prompt's text, `null` for Cancel, `"stop"` for a dialog the + run ended at, or `"missing"` for a dialog `answers` had no answer for. `dialogCount` counts every dialog, and + `dialogsTruncated` is true when the list stopped at its limit. ```json {"name":"run_command","arguments":{"command":"script.run","params":{"source":"vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10});"}}} @@ -1625,6 +1634,46 @@ have no prompts yet. vectorcraft-cli run --cmd script.run --params '{"path": "Tidy Layers.js", "args": {"margin": 12}}' ``` +The help page https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/ +describes the three dialog functions. A script calls them as globals: + +- `alert(message[, title, errorIcon])` shows the message with OK, and an error icon when `errorIcon` is true. It + returns `undefined`. +- `confirm(message[, noAsDflt, title])` shows the message with Yes and No. Yes is the default button, and No is the + default when `noAsDflt` is true. It returns `true` for Yes and `false` for No. +- `prompt(message, preset[, title])` shows the message and a text field that starts with `preset`, with OK and + Cancel. It returns the field's text for OK, an empty string included, and `null` for Cancel. + +The message, the preset and a given title are read as `String(value)` gives them, and `errorIcon` and `noAsDflt` as +`Boolean(value)` gives them. A title that is left out or `undefined` is Script Alert. A call without a message, or a +`prompt` without a preset, throws a `TypeError`. A message or prompt text longer than 16 KiB, or a title longer than +256 bytes, throws a `RangeError` and opens no dialog. The dialog shows line feeds, carriage returns and the two +together as line breaks, and other control characters as their Unicode Control Pictures. The title stays on one +line, with line breaks shown as Control Pictures too. + +In the app, the run waits at the call until the person answers, and spends no instructions while it waits. Enter +gives the default button's answer. Escape gives Cancel for a prompt, No for a confirm and Stop for an alert. Every +dialog also has Stop, which ends the run as `script.stop` does and puts back every document. A dialog can open only +from the script's own code: called inside a callback of a built-in function such as `Array.prototype.forEach`, a +getter or a `toString` conversion, it throws an `Error`. Quitting while a dialog waits asks whether to stop the +script, and Cancel shows the dialog again with the text typed so far. In the app, `ui.dialog.confirm` answers the +open dialog (kind `scriptDialog`) with its `answer` field (`ok`, `yes`, `no`, `cancel` or `stop`), or with the +default button's answer when `answer` is left out. A prompt's `value` longer than 16 KiB is an error, and the +dialog stays open. + +A run with nobody to ask takes its answers from `answers`, a list of `{kind: "alert", value: "ok"}`, `{kind: +"confirm", value: true}` or `{kind: "confirm", value: false}`, and `{kind: "prompt", value: text}` or `{kind: +"prompt", value: null}` for Cancel. Each dialog takes the next answer in order, callbacks of built-in functions +included. The run ends with an error that the script cannot catch, and every document goes back, when no answer is +left, when the next answer is for another kind of dialog, or when the script ends with answers it did not use. +`script.run` refuses a list that is malformed, longer than 10,000 answers or larger than 1 MiB before the run starts, +and refuses any answers for a run in the app, where the person answers. An omitted `answers`, an empty list and an +empty object are the same. + +```sh +vectorcraft-cli run --cmd script.run --params '{"source": "if (confirm(\"Add a frame?\")) vc.log(prompt(\"Name\", \"Frame\"));", "answers": [{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "Border"}]}' +``` + Window › Script Console shows the session's script lines, oldest first: each run's start, its `$.write` and `$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message`, its end, and a report of its time, host calls and slowest commands. Each line shows the run's number and the script's name. Line feeds, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 30743351e..6c08aa5d4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -728,6 +728,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. + - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.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 feda950bc867469ee9a24db611efa471ead004a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:16:00 -0800 Subject: [PATCH 06/40] Plug-ins: live effects of a document's first view share a 2,000 ms budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the app draws a document on screen, the live-effect plug-in runs of its first view share a 2,000 ms budget (VectorCraft's own value); each run takes the lesser of its 5,000 ms limit and what is left. A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input art unchanged and is deferred, and the status bar says how many. Later drawings leave a deferred stage as it is until its input or parameters change. - effect.deferredStatus lists the deferred stages with object, effect index, fill or stroke, plug-in and reason, objects in symbol, pattern, brush and opacity-mask art included. - Effect › Refresh Deferred Effects (effect.refreshDeferred {ids?}) runs them again with a fresh budget and returns {refreshed, stillDeferred, failed}. - Each document has its own budget and deferred stages; exports, Expand Appearance and commands run every stage under the per-call limit. - The web build has no clock for the budget: its first view runs no plug-in effect, and a refresh runs them. Docs: docs/mcp.md, docs/plugins.md, docs/target-app-parity.md. --- crates/effects/src/bake.rs | 2 +- crates/effects/src/geometry.rs | 20 +- crates/effects/src/group.rs | 6 +- crates/effects/src/params.rs | 5 +- crates/effects/src/reshape.rs | 80 +-- crates/engine/src/cmd/effectcmd.rs | 146 ++++++ crates/engine/src/doc_state.rs | 4 + crates/engine/src/tests/plugins.rs | 168 +++++++ crates/plugins/src/deferred.rs | 724 +++++++++++++++++++++++++++ crates/plugins/src/effect.rs | 279 ++++++++++- crates/plugins/src/host.rs | 5 +- crates/plugins/src/lib.rs | 1 + crates/ui-egui/src/caches.rs | 6 +- crates/ui-egui/src/canvas.rs | 34 +- crates/ui-egui/src/canvas/fill.rs | 2 + crates/ui-egui/src/canvas/tests.rs | 47 ++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fi.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hi.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ka.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sv.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/menus/submenus.rs | 1 + crates/ui-egui/src/render_worker.rs | 5 +- docs/mcp.md | 21 +- docs/plugins.md | 13 + docs/target-app-parity.md | 1 + 48 files changed, 1651 insertions(+), 71 deletions(-) create mode 100644 crates/plugins/src/deferred.rs diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index afe256e59..3cc85ab1e 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -395,7 +395,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { if !kept { return None; } - reshape_leaves(&mut m, &fx, Some(d)); + reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 85248e08c..a2e5478b2 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -26,6 +26,10 @@ pub struct GeomContext<'a> { /// The document that the swatches and patterns named by a live-effect plug-in that paints its /// pieces are looked up in. Without one, a result that names any is refused. pub doc: Option<&'a Document>, + /// The object, fill or stroke and effect index a deferred live-effect plug-in is listed by + /// ([`vectorcraft_plugins::deferred`]). When `None`, it is the object `seed` names, `item` and + /// the effect's index in the list being applied. + pub stage: Option, } impl<'a> GeomContext<'a> { @@ -36,7 +40,17 @@ impl<'a> GeomContext<'a> { _ => FillRule::NonZero, }; let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0); - Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None } + Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None, stage: None } + } + + /// The context of effect `e`, one of `list`: unless [`Self::stage`] is set, a live-effect + /// plug-in there is listed by `e`'s index in `list`. + pub(crate) fn at(self, list: &[Effect], e: &Effect) -> Self { + let stage = self.stage.or_else(|| { + let effect = list.iter().position(|x| std::ptr::eq(x, e))?; + Some(vectorcraft_plugins::deferred::Stage { object: self.seed, effect, item: self.item }) + }); + Self { stage, ..self } } /// The context of the effects on `item`, one of the same object's appearance items: a @@ -335,7 +349,7 @@ pub(crate) fn apply_geometry_carrying_within( } let p = merged_params(&e.id, &e.params); let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); - if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { + if let Some(next) = apply_one(&e.id, &p, &cur, b, &ctx.at(effects, e), limit) { if let Some(c) = carry.as_deref_mut() { c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed)); } @@ -377,7 +391,7 @@ fn builds_within(id: &str) -> bool { fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { use util::{join, num}; if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { - let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; + let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item, at: ctx.stage }; return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone()); } match id { diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index d04982d98..73d7266c3 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -209,7 +209,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod Some(area) => Some(reshaped(&area, n, doc)), None => { let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes); - let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes follow the effects as its members do. if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); @@ -225,7 +225,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); crate::reshape::outline_nested_text(doc, &mut art, false); - let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc); + let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc); // The art's own fills and strokes follow the effects as its members do. if carry.moves(&art.appearance) { carry.apply(&mut art.appearance); @@ -738,7 +738,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes paint the members as a whole: their gradients follow // the effects as the members' do. if paint && carry.moves(&m.appearance) { diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index 5c06fae8f..7785aff33 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -161,7 +161,8 @@ pub fn any_plugin_paints_output() -> bool { vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id)) } -/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed. +/// Changes whenever a plug-in is installed or removed (the registry's revision), and when deferred +/// live-effect plug-in stages give results ([`vectorcraft_plugins::deferred::revision`]). pub fn plugin_revision() -> u64 { - vectorcraft_plugins::registry::revision() + vectorcraft_plugins::registry::revision() ^ vectorcraft_plugins::deferred::revision().rotate_left(32) } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index f6c5cf2ae..0bdc13462 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -573,7 +573,7 @@ fn as_art_opts( } _ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?, }; - reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc); + reshape_leaves_apart(&mut art, &fx, &n.appearance.effects, Budget::EFFECTS, apart, paint_doc); if apart { hoist_copies(&mut art); } @@ -636,21 +636,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The /// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path /// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns -/// that live-effect plug-ins painting their pieces name are looked up in `doc` -/// ([`reshape_leaves_apart`]). -pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { - reshape_leaves_within(art, fx, Budget::EFFECTS, doc); +/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one +/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]). +pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) { + reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc); } /// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and /// strokes) follow the effects ([`crate::Carry`]). -pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry { - reshape_leaves_within(art, fx, Budget::EFFECTS, doc) +pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) -> crate::Carry { + reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc) } /// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost. +#[cfg(test)] pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry { - reshape_leaves_apart(art, fx, budget, false, doc) + reshape_leaves_apart(art, fx, &[], budget, false, doc) } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -669,12 +670,15 @@ fn hoist_copies(art: &mut Node) { } } -/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects +/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost; with +/// `apart`, a Transform effect's copies of each path are objects /// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect /// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]), /// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). -/// Returns how gradients that paint the art as a whole follow the effects. -fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { +/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect +/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when +/// `owner` does not hold it. +fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { let mut whole = crate::Carry::default(); if fx.is_empty() { return whole; @@ -693,6 +697,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b }); let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); let style_safe = leaves_safe && !art_stroke; + let listed = fx; let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe); let fx = opt_fx.as_slice(); let seed = art.id.0; @@ -703,12 +708,15 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + // Where a live-effect plug-in sits, which it is listed by when it is deferred. + let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); if let Some(plugin) = painted_plugin(e) { + let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); for_each_leaf(art, &mut |leaf| { let Some((path, _)) = leaf_geometry(leaf) else { return }; let before = Budget::of(&path); let room = max.minus(used.minus(before)); - if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc) { + if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc, at) { used = used.minus(before).plus(made); } }); @@ -778,7 +786,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; } } - let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { + let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { kept_ids.insert(leaf.id); return; }; @@ -1056,10 +1065,18 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling /// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces /// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost /// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are -/// refused, or they would pass `budget`. -fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option { +/// refused, or they would pass `budget`. A deferred run is listed by `at`. +fn paint_leaf( + leaf: &mut Node, + plugin: &str, + params: &serde_json::Value, + bounds: Rect, + budget: Budget, + doc: Option<&Document>, + at: Option, +) -> Option { let (path, rule) = leaf_geometry(leaf)?; - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None, at }; let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); if !budget.fits(made) { @@ -1097,18 +1114,18 @@ fn piece_cost(path: &PathData) -> Budget { /// other fills and strokes; one that gives none keeps the leaf's. Returns /// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, /// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass -/// `budget`. +/// `budget`. A deferred run is listed by `at`. fn paint_piece( leaf: &mut Node, item: &AppearanceItem, - plugin: &str, - params: &serde_json::Value, + (plugin, params): (&str, &serde_json::Value), bounds: Rect, budget: Budget, doc: Option<&Document>, + at: Option, ) -> Option { let (path, rule) = leaf_geometry(leaf)?; - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at }; let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); if !budget.fits(made) { @@ -1241,6 +1258,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt }; let Some(tb) = g.bounds() else { continue }; let params = merged_params(&fx[at].id, &fx[at].params); + // Where each effect the piece keeps sits among the item's effects. + let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); @@ -1250,7 +1269,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt carry.apply(&mut one); } let it = one.items.pop().unwrap_or(it); - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) }; + let stage = vectorcraft_plugins::deferred::Stage { object: ctx.seed, effect: at, item: Some(i) }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i), at: Some(stage) }; let painted = vectorcraft_plugins::effect::apply_painted(plugin, ¶ms, &g, tb, rule, paints, doc) .map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::>()) .filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus))); @@ -1264,7 +1284,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, ctx.seed, limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -1281,7 +1301,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt set_geometry(&mut p, g.clone(), rule); let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); - transform_piece(&mut p, ctx.seed, limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -1297,14 +1317,17 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a /// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: /// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's -/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of +/// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of /// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with /// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path /// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and /// strokes; their other effects stay. The paths keep to `max` together with the rest of the art -/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. -fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source, +/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the +/// piece was made from, and the index among that item's effects of each effect the piece kept, +/// which a deferred live-effect plug-in is listed by. +fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { @@ -1320,8 +1343,9 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: // What the other paths leave of the budget. let before = Budget::of(&path); let room = max.minus(used.minus(before)); + let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) }; if let Some(plugin) = painted_plugin(&e) { - if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) { + if let Some(made) = paint_piece(leaf, &item, (plugin, &merged_params(&e.id, &e.params)), bounds, room, doc, Some(stage)) { *used = used.minus(before).plus(made); } return; @@ -1340,7 +1364,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: AppearanceItem::Stroke(s) => Some(s), AppearanceItem::Fill(_) => None, }; - let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc }; + let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) }; let mut carry = crate::Carry::default(); let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry)); *used = used.minus(before).plus(Budget::of(&out)); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8bf5c9a3d..a6850ab70 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -109,9 +109,155 @@ pub fn specs() -> Vec { has_doc, move_effect ), + cmd!( + query "effect.deferredStatus", + "Deferred Effects", + [], + None, + "{} the live-effect plug-in stages the active document left undrawn: while a document is drawn on screen, the plug-in effects of its first view share a 2,000 ms budget (each run also keeps to its own 5,000 ms limit); a stage left out for want of time, or one that times out, fails or has no plug-in, draws its input art unchanged and stays listed until it gives a result (Effect › Refresh Deferred Effects, an edit to its input or parameters, or an export, which runs every stage under the per-call limit), or until its object is deleted or its effect is removed, moved or hidden (or its fill or stroke is hidden) → {budgetMs: 2000, effects: [{objectId (also an object in the art of a symbol, pattern, brush or opacity mask), effectIndex (in the object's effects, or in the fill's or stroke's), item? (the fill or stroke whose effect it is), pluginId, reason: budget|timeout|missing|failed|safeMode}]} (empty: none)", + has_doc, + deferred_status + ), + cmd!( + "effect.refreshDeferred", + "Refresh Deferred Effects", + ["Effect"], + None, + "{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}", + has_deferred, + refresh_deferred + ), ] } +/// The active document has deferred live-effect plug-in stages ([`deferred_status`]). +fn has_deferred(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if deferred_effects(&st.doc, &st.plugin_effects).is_empty() { Err("no plug-in effect is deferred".into()) } else { Ok(()) } +} + +/// The objects the deferred stages of `fx` name, by id, each with whether it is in `d`'s layers. +/// An object that is not is looked for in the art `d` draws from elsewhere, where live-effect +/// plug-ins run too: the art of its symbols, patterns and brushes, and opacity masks. +fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap, bool)> { + let mut out = std::collections::HashMap::new(); + let mut rest = Vec::new(); + for object in fx.list().into_iter().map(|x| x.stage.object) { + match d.node_arc(NodeId(object)) { + Some(n) => { + out.insert(object, (n.clone(), true)); + } + None => rest.push(object), + } + } + if rest.is_empty() { + return out; + } + let brushes = vectorcraft_brush::library(d); + let symbols = d.symbols.iter().map(|s| &*s.art); + let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a)); + let layers = d.layers.iter().map(|a| &**a); + let mut look = |n: &Node| { + if rest.contains(&n.id.0) { + out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false)); + } + }; + for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) { + vectorcraft_doc::swatches::walk_with_masks(a, &mut look); + } + out +} + +/// Is plug-in `plugin`'s effect at `stage`'s place in `n` (`None`: no such object), and visible, +/// on a visible fill or stroke when it is one's? +fn stage_holds(n: Option<&Node>, stage: vectorcraft_plugins::deferred::Stage, plugin: &str) -> bool { + let Some(n) = n else { return false }; + let list = match stage.item { + None => Some(n.appearance.effects.as_slice()), + Some(i) => n.appearance.items.get(i).filter(|it| it.visible()).map(|it| it.effects().as_slice()), + }; + list.and_then(|l| l.get(stage.effect)).is_some_and(|e| e.visible && e.id == vectorcraft_plugins::effect::effect_id(plugin)) +} + +/// Drops the records of `fx`'s stages whose object or plug-in effect is no longer at their place +/// in `d` ([`stage_holds`]), and gives the objects of the others ([`stage_objects`]). +fn prune(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap, bool)> { + let objects = stage_objects(d, fx); + fx.retain(|stage, plugin| stage_holds(objects.get(&stage.object).map(|(n, _)| &**n), stage, plugin)); + objects +} + +/// The deferred stages of `d` as `effect.deferredStatus` lists them. The records of stages whose +/// object or plug-in effect is no longer at their place in `d` are dropped first. +fn deferred_effects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> Vec { + prune(d, fx); + fx.list() + .into_iter() + .map(|x| { + let mut v = json!({"objectId": x.stage.object, "effectIndex": x.stage.effect, "pluginId": x.plugin, "reason": x.reason.name()}); + if let Some(i) = x.stage.item { + v["item"] = json!(i); + } + v + }) + .collect() +} + +/// `effect.deferredStatus`. +fn deferred_status(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + Ok(json!({"budgetMs": vectorcraft_plugins::deferred::OPENING_BUDGET_MS, "effects": deferred_effects(&st.doc, &st.plugin_effects)})) +} + +/// Draws `n` alone, small, as the canvas would (its live-effect plug-ins run inside the view that +/// applies): what matters is that it is drawn, not its pixels. Is every part of it drawn, none left +/// out for its size? +fn draw_small(renderer: &mut vectorcraft_render::Renderer, doc: &Document, n: &Arc) -> bool { + let Some(b) = n.geometric_bounds() else { return false }; + let scale = 64.0 / b.width().max(b.height()).max(1.0); + let view = vectorcraft_geom::Affine::scale(scale) * vectorcraft_geom::Affine::translate((1.0 - b.x0, 1.0 - b.y0)); + renderer.stats = Default::default(); + renderer.render_node(doc, n, 66, 66, view); + renderer.stats.culled == 0 +} + +/// `effect.refreshDeferred`: each object of the document's layers is drawn again alone, small, +/// inside the document's view, so its stages are run with the input they were deferred with; a +/// hidden object, or one in a hidden layer or group, is not drawn. An object in the art of a +/// symbol, pattern, brush or opacity mask runs where that art is used: the visible layers are +/// drawn then. A stage input left out of a small drawing for its size stays deferred. +fn refresh_deferred(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone()); + let ids = super::named_ids(p)?; + let objects = prune(&doc, &fx); + if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) { + return Err(EngineError::NoNode(*id)); + } + let ids: Option> = ids.map(|ids| ids.iter().map(|id| id.0).collect()); + let counts = fx.refresh(ids.as_deref(), |targets| { + let mut renderer = vectorcraft_render::Renderer::new(); + let mut whole = Vec::new(); + fx.view(|| { + let mut elsewhere = false; + for id in targets { + match objects.get(id) { + Some((n, true)) if doc.is_visible(NodeId(*id)) && draw_small(&mut renderer, &doc, n) => whole.push(*id), + Some((_, false)) => elsewhere = true, + _ => {} + } + } + if elsewhere { + for layer in doc.layers.iter().filter(|l| l.visible) { + draw_small(&mut renderer, &doc, layer); + } + } + }); + whole + }); + Ok(json!({"refreshed": counts.refreshed, "stillDeferred": counts.still_deferred, "failed": counts.failed})) +} + fn ids_json(ids: &[NodeId]) -> Value { json!(ids.iter().map(|i| i.0).collect::>()) } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d0e486240..08b8a2f29 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -110,6 +110,9 @@ pub struct DocState { /// paths the document links to are looked at within them later too, when the Links panel /// asks about a link outside any caller's scope. pub opened_by: Option>, + /// The live-effect plug-in stages deferred while the document is drawn on screen, and the time + /// budget of its first view ([`vectorcraft_plugins::deferred`]). Not saved. + pub plugin_effects: vectorcraft_plugins::deferred::Deferrals, /// What [`DocState::any_empty_artboards`] last measured. Not saved. empty_artboards: std::cell::RefCell, /// What [`DocState::any_art_to_fit`] last measured. Not saved. @@ -326,6 +329,7 @@ impl DocState { last_perspective: None, opened_by: file_access::current(), empty_artboards: Default::default(), + plugin_effects: Default::default(), art_to_fit: Default::default(), } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index aca18dee1..04e7b7784 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -980,6 +980,174 @@ fn painted_pieces_keep_to_the_effects_budget() { assert!((350..=100_000).contains(&paths), "{paths} paths"); } +/// A clock for a document's deferrals that reads `step` milliseconds later each time it is read; +/// the test changes the step as it goes. +fn stepping_clock(step: &std::sync::Arc) -> vectorcraft_plugins::deferred::Clock { + let (now, step) = (std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), step.clone()); + std::sync::Arc::new(move || Some(now.fetch_add(step.load(std::sync::atomic::Ordering::Relaxed), std::sync::atomic::Ordering::Relaxed))) +} + +/// While a document is drawn on screen, the plug-in effects of its first view share a time budget. +/// A stage left out draws its input art, is listed by `effect.deferredStatus`, and stays left out +/// on later drawings; Effect › Refresh Deferred Effects runs it again with a fresh budget, and an +/// export draws every stage. Another document has a budget of its own. +#[test] +fn the_first_view_defers_plug_in_effects_past_its_budget() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 30.0, "#ff0000"); + for (id, x) in [("test.engine.deferred.a", 100.0), ("test.engine.deferred.b", 140.0)] { + let out = json!({"objects": [{"path": square_at(x)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest(id, "effect", "{}"), &out)); + } + run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.a"})); + run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "plugin.test.engine.deferred.b"})); + // Each reading is 3 s later: the first run's result comes back after the budget, and the + // second run doesn't start. + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + let img = fx.view(|| drawn(&s)); + fx.first_view_done(); + assert_eq!((img.pixel(15, 15), img.pixel(35, 15), img.pixel(110, 50), img.pixel(150, 50)), (RED, RED, WHITE, WHITE), "the input art"); + assert_eq!(fx.take_report(), Some(2)); + let status = run(&mut s, "effect.deferredStatus", json!({})); + let listed = |id: NodeId, plugin: &str| json!({"objectId": id.0, "effectIndex": 0, "pluginId": plugin, "reason": "budget"}); + assert_eq!(status, json!({"budgetMs": 2000, "effects": [listed(a, "test.engine.deferred.a"), listed(b, "test.engine.deferred.b")]})); + assert_eq!(fx.view(|| drawn(&s)).pixel(110, 50), WHITE, "a later drawing leaves it deferred"); + + // Refreshed with a fresh budget, in which the clock stands still. + assert!(s.execute("effect.refreshDeferred", &json!({"ids": ["a"]})).is_err()); + assert!(s.execute("effect.refreshDeferred", &json!({"ids": [999_999]})).is_err(), "not an object of the document"); + step.store(0, std::sync::atomic::Ordering::Relaxed); + let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0]})); + assert_eq!(refreshed, json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + let img = fx.view(|| drawn(&s)); + assert_eq!((img.pixel(110, 50), img.pixel(150, 50)), (RED, WHITE)); + assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([listed(b, "test.engine.deferred.b")])); + + // An export draws every stage; the view then uses the result. + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(svg.contains(r#">(); + assert_eq!(places, [(a.0, None, 1), (a.0, None, 2), (b.0, Some(0), 1), (b.0, Some(0), 2)]); +} + +/// A refresh that does not draw a stage (its object, or a group holding it, is hidden) leaves it +/// deferred, and a stage whose effect has been removed or hidden, or whose object has been removed, +/// is no longer listed and does not enable Effect › Refresh Deferred Effects. +#[test] +fn refresh_keeps_stages_it_did_not_draw_and_drops_removed_ones() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 30.0, "#ff0000"); + let c = rect(&mut s, 50.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.kept", "effect", "{}"), &out)); + run(&mut s, "effect.apply", json!({"ids": [a.0, b.0, c.0], "effect": "plugin.test.engine.deferred.kept"})); + run(&mut s, "select.set", json!({"ids": [c.0]})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + fx.view(|| drawn(&s)); + fx.first_view_done(); + let listed = |s: &mut Session| { + run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::>() + }; + assert_eq!(listed(&mut s), [a.0, b.0, c.0]); + + s.execute("object.hide", &json!({"ids": [a.0, g.0]})).unwrap(); + step.store(0, std::sync::atomic::Ordering::Relaxed); + let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0, c.0]})); + assert_eq!(refreshed, json!({"refreshed": 0, "stillDeferred": 2, "failed": 0}), "a hidden object is not drawn"); + assert_eq!(listed(&mut s), [a.0, b.0, c.0]); + + run(&mut s, "effect.remove", json!({"ids": [a.0], "index": 0})); + assert_eq!(listed(&mut s), [b.0, c.0], "its effect was removed"); + run(&mut s, "effect.setParams", json!({"ids": [b.0], "index": 0, "visible": false})); + assert_eq!(listed(&mut s), [c.0], "its effect was hidden"); + s.execute("edit.clear", &json!({"ids": [b.0, g.0]})).unwrap(); + assert_eq!(listed(&mut s), Vec::::new(), "its object was deleted"); + assert!(s.execute("effect.refreshDeferred", &json!({})).is_err(), "nothing is deferred"); +} + +/// A deferred stage in a symbol's art is listed by the object in that art, and a refresh runs it +/// where the symbol is placed. +#[test] +fn deferred_stages_in_symbol_art_are_listed_and_refreshed() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.symbol", "effect", "{}"), &out)); + run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.symbol"})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let instance = run(&mut s, "symbol.new", json!({"name": "Deferred"}))["id"].as_u64().unwrap(); + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + fx.view(|| drawn(&s)); + fx.first_view_done(); + assert_eq!(fx.take_report(), Some(1)); + let effects = run(&mut s, "effect.deferredStatus", json!({}))["effects"].clone(); + let object = effects[0]["objectId"].as_u64().unwrap(); + assert_eq!(effects.as_array().map(Vec::len), Some(1), "{effects}"); + assert!(object != instance && s.doc().unwrap().doc.node(NodeId(object)).is_none(), "an object of the symbol's art: {effects}"); + + step.store(0, std::sync::atomic::Ordering::Relaxed); + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([])); +} + /// `CRAFT_PLUGIN_PAINTS_WASM=` saves a test live effect to try in the app /// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its /// object's stroke is 5 pt wide, and hands the geometry back otherwise. diff --git a/crates/plugins/src/deferred.rs b/crates/plugins/src/deferred.rs new file mode 100644 index 000000000..f817f32d9 --- /dev/null +++ b/crates/plugins/src/deferred.rs @@ -0,0 +1,724 @@ +//! Live-effect plug-ins while a document opens: the plug-in effects drawn for its first view share +//! a time budget of [`OPENING_BUDGET_MS`], and a stage that is not run for want of time, or that +//! gives no result, is deferred: it draws its input art and is listed with the reason +//! ([`Deferrals::list`]) until it runs again. +//! +//! One [`Deferrals`] belongs to each open document. Drawing that document on screen runs inside +//! [`Deferrals::view`], which applies its budget and its deferred stages to the plug-in effects +//! evaluated on that thread ([`crate::effect`]): +//! +//! - until [`Deferrals::first_view_done`], every run takes the lesser of its per-call limit and +//! what is left of the budget, no run starts once the budget is spent, and a result that comes +//! back after the budget ran out is not used; +//! - afterwards a deferred stage is not run again while its input and parameters stay the same; a +//! stage whose input or parameters changed runs under the per-call limit; +//! - [`Deferrals::refresh`] runs deferred stages again with a fresh budget. +//! +//! Work outside a view (exports, commands, drawing without a screen) runs every stage under the +//! per-call limit. When such a run gives a result for a stage some document has deferred, +//! [`revision`] changes, so that drawings made with the deferred stage are drawn again. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +/// The time the plug-in effects drawn for a document's first view share, in milliseconds. +pub const OPENING_BUDGET_MS: u64 = 2_000; + +/// Why a stage is deferred. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Reason { + /// It was not run, or its result came too late, because the budget was spent. + Budget, + /// It ran to its per-call time limit. + Timeout, + /// Its plug-in isn't installed as a live effect. + Missing, + /// It trapped, panicked, returned art that was refused, or its parameters were refused. + Failed, + /// Safe Mode is on: no plug-in runs. + SafeMode, +} + +impl Reason { + /// The reason as `effect.deferredStatus` names it. + pub fn name(self) -> &'static str { + match self { + Reason::Budget => "budget", + Reason::Timeout => "timeout", + Reason::Missing => "missing", + Reason::Failed => "failed", + Reason::SafeMode => "safeMode", + } + } +} + +/// Where a plug-in effect sits: the object, the index of the effect in its list, and the fill or +/// stroke whose list that is (`None`: the object's own effects). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Stage { + pub object: u64, + pub effect: usize, + pub item: Option, +} + +/// A deferred stage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Deferred { + pub stage: Stage, + /// The plug-in's id. + pub plugin: String, + pub reason: Reason, + /// The input and parameters the stage was deferred with (the effect cache's key), 0 when the + /// plug-in was not looked at (Safe Mode, a missing plug-in). + pub(crate) key: u64, +} + +/// What [`Deferrals::refresh`] did with the stages it ran again. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Refreshed { + /// Stages that gave a result. + pub refreshed: usize, + /// Stages left deferred for want of time, or with input the refresh did not run. + pub still_deferred: usize, + /// Stages that ran out of their per-call time, failed or had no plug-in again. + pub failed: usize, +} + +/// Milliseconds on a monotonic clock, `None` where there is none (the web build). +pub type Clock = Arc Option + Send + Sync>; + +/// Milliseconds since the first reading in this process (native builds); `None` on the web build, +/// whose `std` has no monotonic clock. +fn monotonic() -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + let start = START.get_or_init(std::time::Instant::now); + Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)) + } + #[cfg(target_arch = "wasm32")] + None +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Phase { + /// Runs share a budget that started at `started` (`None`: no run yet). `first`: the first + /// view, which reports what it deferred when it ends. + Budgeted { started: Option, first: bool }, + /// Deferred stages are left as they are while their input stays the same. + Open, +} + +struct State { + phase: Phase, + deferred: Vec, + /// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it. + report: Option, + /// The refresh running, if one is ([`Deferrals::refresh`]). + refresh: Option, + clock: Clock, +} + +/// A [`Deferrals::refresh`] while it runs. It applies only to the thread that runs it: drawings +/// of the document on other threads go on as if no refresh ran. +struct Refresh { + thread: std::thread::ThreadId, + /// When its budget started (`None`: no run yet). + started: Option, + /// The objects whose stages run again; the others' stay deferred. + ids: Vec, + /// The cache keys whose cached failures have been run again. + retried: Vec, + /// The stages of `ids` that were drawn. + visited: Vec, + /// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again. + touched: Vec<(Stage, u64)>, +} + +impl State { + /// The refresh running on this thread. + fn refresh_here(&mut self) -> Option<&mut Refresh> { + let here = std::thread::current().id(); + self.refresh.as_mut().filter(|r| r.thread == here) + } + + /// The phase that applies on this thread: a refresh running here has a budget of its own. + fn phase_here(&mut self) -> Phase { + match self.refresh_here() { + Some(r) => Phase::Budgeted { started: r.started, first: false }, + None => self.phase, + } + } + + /// Is a refresh running here, and does it run the stages of `object`? + fn refreshes(&mut self, object: u64) -> Option { + self.refresh_here().map(|r| r.ids.contains(&object)) + } +} + +impl Drop for State { + fn drop(&mut self) { + for d in &self.deferred { + unmark(d.key); + } + } +} + +/// The deferred stages and the opening budget of one open document. Clones share them. +#[derive(Clone)] +pub struct Deferrals(Arc>); + +impl Default for Deferrals { + fn default() -> Self { + Self::with_clock(Arc::new(monotonic)) + } +} + +impl std::fmt::Debug for Deferrals { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Deferrals").field("deferred", &self.list()).finish() + } +} + +/// What a budgeted run may take. +pub(crate) enum Allowance { + /// No budget applies: the per-call limit. + Unlimited, + /// At most this many milliseconds, which end the budget at `until`. + Until { ms: u64, until: u64 }, + /// The budget is spent: the stage is deferred without running. + Spent, +} + +impl Deferrals { + /// A document's deferrals before its first view, timed by the monotonic clock. + pub fn new() -> Self { + Self::default() + } + + /// [`Deferrals::new`] timed by `clock` (tests give a clock of their own). + pub fn with_clock(clock: Clock) -> Self { + Self(Arc::new(Mutex::new(State { + phase: Phase::Budgeted { started: None, first: true }, + deferred: Vec::new(), + report: None, + refresh: None, + clock, + }))) + } + + fn state(&self) -> std::sync::MutexGuard<'_, State> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The deferred stages, by object and effect, each once (a stage drawn with several inputs, + /// such as the paths of a group, is listed with the first one's reason). + pub fn list(&self) -> Vec { + let mut out = self.state().deferred.clone(); + out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect)); + out.dedup_by_key(|d| d.stage); + out + } + + /// Is the first view still being drawn ([`Deferrals::first_view_done`] not called yet)? + pub fn opening(&self) -> bool { + matches!(self.state().phase, Phase::Budgeted { first: true, .. }) + } + + /// Runs `f` with these deferrals applied to the plug-in effects evaluated on this thread. + pub fn view(&self, f: impl FnOnce() -> T) -> T { + let previous = VIEW.with(|v| v.borrow_mut().replace(self.clone())); + let _ = PASSES.try_with(|p| p.borrow_mut().push(Pass::default())); + let _restore = Restore { previous, current: self.clone() }; + f() + } + + /// One drawing inside [`Deferrals::view`] has ended: a stage that ran after the first view, + /// whether it gave a result or was deferred again, keeps only the records of the inputs that + /// drawing left deferred. The records of its other inputs were made with input it no longer + /// has. + fn settle(&self, pass: Pass) { + if pass.ran.is_empty() { + return; + } + self.state().deferred.retain(|d| { + let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key)); + if !keep { + unmark(d.key); + } + keep + }); + } + + /// The first view has been drawn: later drawings leave deferred stages deferred, and + /// [`Deferrals::take_report`] gives how many there are. Does nothing after the first call. + pub fn first_view_done(&self) { + let mut s = self.state(); + if let Phase::Budgeted { first: true, .. } = s.phase { + s.phase = Phase::Open; + s.report = Some(s.stages().len()); + } + } + + /// How many stages the first view left deferred, once, after it was drawn. + pub fn take_report(&self) -> Option { + self.state().report.take() + } + + /// Runs the deferred stages of the objects in `objects` (all of them when `None`) again with a + /// fresh budget: `run` is given those objects, draws them inside [`Deferrals::view`] on this + /// thread, and returns the objects it drew whole, every part of them reached. A stage the + /// drawing does not reach (that of a hidden object, say) stays deferred as it was, and so does + /// a stage input it does not reach, unless the object was drawn whole: the input is then one + /// the stage no longer has. The refresh and its budget apply only to this thread. + pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec) -> Refreshed { + let (targets, ids) = { + let mut s = self.state(); + let mut targets: Vec = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect(); + targets.sort_by_key(|t| (t.object, t.item, t.effect)); + targets.dedup(); + let mut ids: Vec = targets.iter().map(|t| t.object).collect(); + ids.dedup(); + let thread = std::thread::current().id(); + let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new()); + s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched }); + (targets, ids) + }; + // The refresh ends when `run` returns, also when it unwinds. + struct Reopen<'a>(&'a Deferrals); + impl Drop for Reopen<'_> { + fn drop(&mut self) { + self.0.state().refresh = None; + } + } + let reopen = Reopen(self); + let whole = run(&ids); + let mut s = self.state(); + let touched = match s.refresh.take() { + Some(r) => { + s.deferred.retain(|d| { + let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key)); + if gone { + unmark(d.key); + } + !gone + }); + r.touched + } + None => Vec::new(), + }; + drop(s); + drop(reopen); + // A stage fails again when an input it ran with this time is deferred for a reason other + // than time; one with records left that this refresh did not run is still deferred. + let s = self.state(); + let mut out = Refreshed::default(); + for t in &targets { + let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable(); + if left.peek().is_none() { + out.refreshed += 1; + } else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) { + out.failed += 1; + } else { + out.still_deferred += 1; + } + } + drop(s); + if out.refreshed > 0 { + REVISION.fetch_add(1, Ordering::Relaxed); + } + out + } + + /// Records `stage` with cache key `key` (of plug-in `plugin`) as deferred for `reason`, in + /// place of what was recorded for that stage and key. After the first view, when the drawing + /// ends, the stage's records of inputs that drawing did not leave deferred are dropped + /// ([`Deferrals::settle`]). + pub(crate) fn defer(&self, stage: Stage, plugin: &str, reason: Reason, key: u64) { + seen(stage, key); + let mut s = self.state(); + s.remove(stage, key); + mark(key); + s.deferred.push(Deferred { stage, plugin: plugin.to_string(), reason, key }); + s.ran_here(stage, key); + } + + /// `stage` gave a result for cache key `key`, from a run or from the cache: it is no longer + /// deferred with that input. After the first view, when the drawing ends, the stage's records + /// of inputs that drawing did not leave deferred are dropped as well ([`Deferrals::settle`]). + pub(crate) fn ran(&self, stage: Stage, key: u64) { + let mut s = self.state(); + s.remove(stage, key); + s.ran_here(stage, key); + } + + /// Should `stage`, with cache key `key`, be left deferred without running? After the first + /// view, a stage deferred with the same input and parameters is, and so is one of an object a + /// refresh does not run. + pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool { + let mut s = self.state(); + let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false); + let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key); + drop(s); + if held { + seen(stage, key); + } + held + } + + /// `stage` is being drawn: a refresh running here that runs its object's stages notes that it + /// reached the stage. + pub(crate) fn visit(&self, stage: Stage) { + let mut s = self.state(); + if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) { + r.visited.push(stage); + } + } + + /// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is + /// false, such as those of effects or objects removed from the document. + pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) { + self.state().deferred.retain(|d| { + let k = keep(d.stage, &d.plugin); + if !k { + unmark(d.key); + } + k + }); + } + + /// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key + /// while a refresh running here runs the stages of `stage`'s object. + pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool { + let mut s = self.state(); + let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false }; + r.retried.push(key); + true + } + + /// What a run starting now may take of the budget: the budget starts with the first run. + /// Without a clock, the first view runs nothing and a refresh runs every stage under its + /// per-call limit. + pub(crate) fn allowance(&self) -> Allowance { + let mut s = self.state(); + let Phase::Budgeted { started, first } = s.phase_here() else { return Allowance::Unlimited }; + let Some(now) = (s.clock)() else { return if first { Allowance::Spent } else { Allowance::Unlimited } }; + let start = started.unwrap_or(now); + match s.refresh_here() { + Some(r) => r.started = Some(start), + None => s.phase = Phase::Budgeted { started: Some(start), first }, + } + let until = start.saturating_add(OPENING_BUDGET_MS); + match until.checked_sub(now).filter(|ms| *ms > 0) { + Some(ms) => Allowance::Until { ms, until }, + None => Allowance::Spent, + } + } + + /// Has the clock passed `until` (a result that came back now is too late)? Without a clock, + /// yes. + pub(crate) fn passed(&self, until: u64) -> bool { + let clock = self.state().clock.clone(); + clock().is_none_or(|now| now > until) + } +} + +impl State { + /// Removes what is recorded for `stage` with cache key `key`. A record without a key (the + /// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0 + /// removes every record of the stage. + fn remove(&mut self, stage: Stage, key: u64) { + self.deferred.retain(|d| { + let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0); + if !keep { + unmark(d.key); + } + keep + }); + } + + /// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this + /// thread: a refresh running here notes the input ([`Deferrals::refresh`]), and after the first + /// view [`Deferrals::settle`] looks at the stage when that drawing ends. + fn ran_here(&mut self, stage: Stage, key: u64) { + if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) { + r.touched.push((stage, key)); + } + if self.phase_here() == Phase::Open { + let _ = PASSES.try_with(|p| { + if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) { + pass.ran.push(stage); + } + }); + } + } + + /// The deferred stages, each once. + fn stages(&self) -> Vec { + let mut out: Vec = self.deferred.iter().map(|d| d.stage).collect(); + out.sort_by_key(|t| (t.object, t.item, t.effect)); + out.dedup(); + out + } +} + +thread_local! { + static VIEW: RefCell> = const { RefCell::new(None) }; + /// One entry for each [`Deferrals::view`] running on this thread, innermost last. + static PASSES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// What one drawing inside a view did: the stages that ran after the first view (with a result or +/// deferred again), and the stage inputs it left deferred. +#[derive(Default)] +struct Pass { + ran: Vec, + seen: Vec<(Stage, u64)>, +} + +/// The drawing on this thread left `stage` deferred with cache key `key`. +fn seen(stage: Stage, key: u64) { + let _ = PASSES.try_with(|p| { + if let Some(pass) = p.borrow_mut().last_mut() { + pass.seen.push((stage, key)); + } + }); +} + +/// Ends the drawing and puts the previous view back, also when the work inside unwinds. +struct Restore { + previous: Option, + current: Deferrals, +} + +impl Drop for Restore { + fn drop(&mut self) { + if let Some(pass) = PASSES.try_with(|p| p.borrow_mut().pop()).ok().flatten() { + self.current.settle(pass); + } + let previous = self.previous.take(); + let _ = VIEW.try_with(|v| *v.borrow_mut() = previous); + } +} + +/// The deferrals of the view being drawn on this thread ([`Deferrals::view`]). +pub fn current() -> Option { + VIEW.try_with(|v| v.borrow().clone()).ok().flatten() +} + +/// Changes when deferred stages give results: [`Deferrals::refresh`] ran some, or a run outside a +/// view gave a result for a stage some document has deferred. +pub fn revision() -> u64 { + REVISION.load(Ordering::Relaxed) +} + +static REVISION: AtomicU64 = AtomicU64::new(0); + +/// The cache keys some document's deferred stages were deferred with, and how many stages hold each. +static MARKED: Mutex>> = Mutex::new(None); + +fn mark(key: u64) { + if key != 0 { + *MARKED.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).entry(key).or_default() += 1; + } +} + +fn unmark(key: u64) { + let mut marked = MARKED.lock().unwrap_or_else(|e| e.into_inner()); + let Some(m) = marked.as_mut() else { return }; + if let Some(n) = m.get_mut(&key) { + *n -= 1; + if *n == 0 { + m.remove(&key); + } + } +} + +/// A run gave a result for cache key `key`: when some document deferred a stage with that key, +/// [`revision`] changes. +pub(crate) fn resolved(key: u64) { + let marked = MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key)); + if marked { + REVISION.fetch_add(1, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A clock that reads `step` milliseconds later each time it is read. + fn stepping(step: u64) -> Clock { + let t = Arc::new(AtomicU64::new(0)); + Arc::new(move || Some(t.fetch_add(step, Ordering::Relaxed))) + } + + #[test] + fn the_budget_starts_with_the_first_run_and_ends_after_it() { + let d = Deferrals::with_clock(stepping(700)); + assert!(matches!(d.allowance(), Allowance::Until { ms: 2_000, until: 2_000 })); + assert!(matches!(d.allowance(), Allowance::Until { ms: 1_300, until: 2_000 })); + assert!(matches!(d.allowance(), Allowance::Until { ms: 600, until: 2_000 })); + assert!(matches!(d.allowance(), Allowance::Spent)); + d.first_view_done(); + assert!(matches!(d.allowance(), Allowance::Unlimited)); + } + + #[test] + fn without_a_clock_the_first_view_runs_nothing_and_a_refresh_everything() { + let d = Deferrals::with_clock(Arc::new(|| None)); + assert!(matches!(d.allowance(), Allowance::Spent)); + assert!(d.passed(u64::MAX)); + d.first_view_done(); + d.defer(Stage::default(), "a", Reason::Budget, 31); + d.refresh(None, |_| { + assert!(matches!(d.allowance(), Allowance::Unlimited)); + vec![] + }); + } + + /// A stage drawn with several inputs (the paths of a group) holds one record for each, and is + /// listed and counted once. + #[test] + fn a_stage_is_listed_and_counted_once() { + let d = Deferrals::with_clock(stepping(1)); + let s = Stage { object: 7, effect: 1, item: None }; + d.defer(s, "a", Reason::Budget, 11); + d.defer(s, "a", Reason::Budget, 12); + d.defer(s, "a", Reason::Timeout, 12); + d.defer(Stage { item: Some(0), ..s }, "a", Reason::Failed, 13); + assert_eq!(d.list().len(), 2); + assert!(!d.holds(s, 12), "the first view runs deferred stages"); + d.first_view_done(); + assert!(d.holds(s, 11) && d.holds(s, 12)); + assert!(!d.holds(s, 14), "other input"); + assert_eq!(d.take_report(), Some(2)); + assert_eq!(d.take_report(), None); + d.first_view_done(); + assert_eq!(d.take_report(), None, "the first view ends once"); + d.ran(s, 11); + assert_eq!(d.list().len(), 2, "the stage is still deferred with its other input"); + d.ran(s, 12); + assert_eq!(d.list().len(), 1); + d.defer(s, "a", Reason::Missing, 0); + d.ran(s, 15); + assert_eq!(d.list().len(), 1, "a record without a key goes with any result"); + } + + /// A refresh runs the deferred stages of the objects asked for with a fresh budget, and counts + /// what became of them; the stages its drawing did not reach stay as they were. Another + /// thread draws the document as if no refresh ran. + #[test] + fn a_refresh_counts_what_became_of_each_stage() { + let d = Deferrals::with_clock(stepping(1)); + d.first_view_done(); + let st = |object, effect| Stage { object, effect, item: None }; + let stages = [ + (st(1, 0), Reason::Budget), + (st(1, 1), Reason::Budget), + (st(1, 2), Reason::Budget), + (st(1, 3), Reason::Timeout), + (st(2, 0), Reason::Budget), + (st(3, 0), Reason::Failed), + ]; + for (stage, reason) in stages { + d.defer(stage, "a", reason, 40 + stage.object * 4 + stage.effect as u64); + } + let seen = std::cell::RefCell::new(vec![]); + let counts = d.refresh(Some(&[1, 3, 9]), |ids| { + seen.borrow_mut().extend_from_slice(ids); + assert!(matches!(d.allowance(), Allowance::Until { .. }), "a fresh budget"); + assert!(d.holds(st(2, 0), 48), "an object not asked for stays deferred"); + d.visit(st(2, 0)); + d.visit(st(1, 0)); + d.visit(st(1, 1)); + d.defer(st(1, 1), "a", Reason::Budget, 45); + std::thread::scope(|scope| { + let other = scope.spawn(|| (matches!(d.allowance(), Allowance::Unlimited), d.holds(st(1, 2), 46), d.retries(st(3, 0), 52))); + assert_eq!(other.join().unwrap(), (true, true, false), "another thread"); + }); + d.visit(st(1, 1)); + assert_eq!(d.list().iter().filter(|x| x.stage == st(1, 1)).count(), 1, "a stage is reached once"); + d.visit(st(3, 0)); + d.defer(st(3, 0), "a", Reason::Failed, 52); + ids.to_vec() + }); + assert_eq!(*seen.borrow(), [1, 3]); + // st(1, 3) timed out before and was not reached: still deferred, not failed again. + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 3, failed: 1 }); + assert!(matches!(d.allowance(), Allowance::Unlimited), "the budget ends with the refresh"); + let left = d.list().iter().map(|x| x.stage).collect::>(); + assert_eq!(left, [st(1, 1), st(1, 2), st(1, 3), st(2, 0), st(3, 0)]); + + // An object the drawing did not reach whole keeps the inputs it did not reach: a reached + // stage of it gives a result only for the inputs that ran. + d.defer(st(1, 1), "a", Reason::Budget, 70); + let counts = d.refresh(Some(&[1]), |_| { + d.visit(st(1, 1)); + d.ran(st(1, 1), 45); + vec![] + }); + assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 3, failed: 0 }); + let keys = |stage| d.state().deferred.iter().filter(|x| x.stage == stage).map(|x| x.key).collect::>(); + assert_eq!(keys(st(1, 1)), [70]); + let counts = d.refresh(Some(&[1]), |_| { + d.visit(st(1, 1)); + vec![1] + }); + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 0 }, "drawn whole: an input it no longer has"); + assert_eq!(keys(st(1, 1)), Vec::::new()); + d.retain(|stage, _| stage.object != 1); + assert_eq!(d.list().iter().map(|x| x.stage).collect::>(), [st(2, 0), st(3, 0)]); + } + + /// After the first view, a stage drawn with several inputs (the paths of a group) whose one + /// changed input runs keeps the records of the inputs the same drawing left deferred, and drops + /// the record of the input it no longer has. + #[test] + fn a_stage_that_runs_keeps_the_inputs_its_drawing_left_deferred() { + let d = Deferrals::with_clock(stepping(1)); + let s = Stage { object: 4, effect: 0, item: None }; + let other = Stage { object: 5, ..s }; + for key in [61, 62] { + d.defer(s, "a", Reason::Budget, key); + } + d.defer(other, "a", Reason::Budget, 63); + d.first_view_done(); + d.view(|| { + assert!(d.holds(s, 61), "an unchanged path"); + assert!(!d.holds(s, 64), "the changed path"); + d.ran(s, 64); + }); + let keys = d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::>(); + assert_eq!(keys, [(4, 61), (5, 63)]); + + // A changed input that fails again replaces the record of the input it no longer has. + d.view(|| { + assert!(!d.holds(other, 65)); + d.defer(other, "a", Reason::Failed, 65); + }); + let left = d.state().deferred.iter().map(|x| (x.stage.object, x.key, x.reason)).collect::>(); + assert_eq!(left, [(4, 61, Reason::Budget), (5, 65, Reason::Failed)]); + } + + #[test] + fn a_result_for_a_deferred_key_changes_the_revision() { + const KEY: u64 = 0xdef0_0001; + let marked = || MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&KEY)); + let d = Deferrals::with_clock(stepping(1)); + assert!(!marked()); + d.defer(Stage::default(), "a", Reason::Budget, KEY); + let other = d.clone(); + other.defer(Stage { object: 2, ..Stage::default() }, "a", Reason::Budget, KEY); + assert!(marked()); + let before = revision(); + resolved(KEY); + assert!(revision() > before); + other.ran(Stage::default(), KEY); + assert!(marked(), "another stage holds the key"); + drop((d, other)); + assert!(!marked(), "the document closed"); + } +} diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index 01cedbcc0..f06d17e24 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -12,7 +12,8 @@ //! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when //! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and //! reported by [`last_error`]. The swatches and patterns a painted result names are checked -//! against the document each time it is used. +//! against the document each time it is used. While a document is drawn on screen, its first view +//! shares a time budget and stages without a result are deferred ([`crate::deferred`]). use std::collections::HashMap; use std::hash::{Hash, Hasher}; @@ -22,6 +23,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, AppearanceItem, Document}; use vectorcraft_geom::{FillRule, PathData, Rect}; +use crate::deferred::{Allowance, Reason, Stage}; use crate::objects::{decode, output_geometry, paint_lists}; use crate::painted::{self, Piece, Scope}; use crate::{Error, Kind, Limits, Result, registry}; @@ -69,6 +71,9 @@ pub struct Paints<'a> { /// The index in `appearance.items` of the fill or stroke whose own effects hold this one; /// `None`: one of the object's effects. pub item: Option, + /// The object and the effect's place in its list, which a deferred stage is listed by + /// ([`crate::deferred`]); `None`: not known, listed as object 0. + pub at: Option, } /// [`Paints`] as the plug-in is given them: the input object's `fills` and `strokes`, and @@ -151,7 +156,8 @@ impl Out { #[derive(Default)] struct Cache { - entries: HashMap)>, + /// Each result, or why the run gave none ([`Reason::Timeout`] or [`Reason::Failed`]). + entries: HashMap)>, /// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]). anchors: usize, } @@ -294,9 +300,10 @@ pub fn paints_output(id: &str) -> bool { /// For an effect that returns painted pieces, the geometry is every piece's path in order, and /// `None` when the swatches and patterns they name are refused as [`apply_painted`] refuses them. pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints, doc: Option<&Document>) -> Option { + let at = paints.at; let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?; if let Out::Pieces(pieces) = &out { - references_found(id, &plugin, pieces, doc)?; + references_found(id, &plugin, pieces, doc, at)?; } Some(out.geometry()) } @@ -318,9 +325,10 @@ pub fn apply_painted( paints: Paints, doc: Option<&Document>, ) -> Option>> { + let at = paints.at; let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?; let Out::Pieces(pieces) = out else { return None }; - references_found(id, &plugin, &pieces, doc)?; + references_found(id, &plugin, &pieces, doc, at)?; Some(match doc { Some(d) => painted::with_swatch_colors(pieces, d), None => pieces, @@ -329,11 +337,17 @@ pub fn apply_painted( /// `Some` when every swatch and pattern `pieces` name is in `doc` ([`painted::check_references`]), /// recording the outcome for [`last_error`] when there is a document. The document's swatches and -/// patterns can change while a result stays cached, so each use checks them. -fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> { +/// patterns can change while a result stays cached, so each use checks them. Inside a document's +/// view, a refusal defers stage `at` as failed. +fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>, at: Option) -> Option<()> { let checked = painted::check_references(pieces, doc); if doc.is_some() { note(id, plugin, Origin::References, checked.as_ref().map(|_| ())); + if checked.is_err() + && let Some(v) = crate::deferred::current() + { + v.defer(at.unwrap_or_default(), id, Reason::Failed, 0); + } } checked.ok() } @@ -341,12 +355,36 @@ fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Opt /// Runs plug-in `id` as [`apply`] says, or takes its result from the cache: the plug-in and what /// it gave. The launch marker names the plug-in during its first run in this process, and until /// every run that started while it ran has ended too ([`crate::safe_mode::loading`]). +/// +/// Inside a document's view ([`crate::deferred`]), a stage without a result is recorded as +/// deferred with the reason, and one with a result is no longer deferred. During the view's +/// budget a run takes at most what is left of it, none starts once it is spent, and a result +/// that comes back after it ran out is not used; neither is cached or reported by +/// [`last_error`]. After the first view, a stage deferred with the same input is not run again. fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<(Arc, Out)> { + let view = crate::deferred::current(); + let stage = paints.at.unwrap_or_default(); + if let Some(v) = &view { + v.visit(stage); + } + let defer = |reason, key| { + if let Some(v) = &view { + v.defer(stage, id, reason, key); + } + }; if crate::safe_mode::active() { + defer(Reason::SafeMode, 0); return None; } - let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?; - let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?); + let Some(plugin) = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect) else { + defer(Reason::Missing, 0); + return None; + }; + let Ok(resolved) = plugin.manifest().resolve_params(params) else { + defer(Reason::Failed, 0); + return None; + }; + let resolved = Value::Object(resolved); let given = paints.given(); let key = Key { serial: plugin.serial(), @@ -357,14 +395,44 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR path: path.clone(), }; let h = key.hash64(); - if let Some((k, out)) = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.entries.get(&h)) - && *k == key - { - return out.clone().map(|o| (plugin, o)); + let cached = CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|c| c.entries.get(&h)) + .filter(|(k, _)| *k == key) + .map(|(_, out)| out.clone()); + match cached { + // A result from the cache counts as a run that gave it. + Some(Ok(o)) => { + if let Some(v) = &view { + v.ran(stage, h); + } + return Some((plugin, o)); + } + // A refresh runs a stage that failed again. + Some(Err(reason)) if !view.as_ref().is_some_and(|v| v.retries(stage, h)) => { + defer(reason, h); + return None; + } + _ => {} } + let allowance = match &view { + Some(v) if v.holds(stage, h) => return None, + Some(v) => v.allowance(), + None => Allowance::Unlimited, + }; + let (wall_time_ms, until) = match allowance { + Allowance::Unlimited => (WALL_TIME_MS, None), + Allowance::Until { ms, until } => (ms.min(WALL_TIME_MS), Some(until)), + Allowance::Spent => { + defer(Reason::Budget, h); + return None; + } + }; let scope = paints.scope(); let guarded = begin_run(&plugin); - let r = run(&plugin, resolved, path, bounds, rule, given, scope); + let r = run(&plugin, resolved, path, (bounds, rule), given, scope, wall_time_ms); let cancelled = matches!(r, Err(Error::Cancelled)); if guarded { end_run(plugin.serial(), cancelled); @@ -373,11 +441,28 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR if cancelled { return None; } + let timed_out = matches!(&r, Err(Error::Limit(l)) if l == crate::host::TIME_BUDGET); + // A run the budget cut short, or whose result came after the budget ran out, says nothing + // about the plug-in either: the stage waits for a later run. + let late = until.is_some_and(|u| view.as_ref().is_some_and(|v| v.passed(u))); + if (timed_out && wall_time_ms < WALL_TIME_MS) || late { + defer(Reason::Budget, h); + return None; + } note(id, &plugin, Origin::Run, r.as_ref().map(|_| ())); - let out = r.ok(); - let size = weight(&key, out.as_ref()); + let out = r.map_err(|_| if timed_out { Reason::Timeout } else { Reason::Failed }); + match &out { + Ok(_) => { + if let Some(v) = &view { + v.ran(stage, h); + } + crate::deferred::resolved(h); + } + Err(reason) => defer(*reason, h), + } + let size = weight(&key, out.as_ref().ok()); if size > CACHE_ANCHORS / 4 { - return out.map(|o| (plugin, o)); + return out.ok().map(|o| (plugin, o)); } let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); let cache = cache.get_or_insert_with(Cache::default); @@ -385,15 +470,24 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR *cache = Cache::default(); } if let Some((old, old_out)) = cache.entries.insert(h, (key, out.clone())) { - cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref())); + cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref().ok())); } cache.anchors += size; - out.map(|o| (plugin, o)) + out.ok().map(|o| (plugin, o)) } -/// One run of `plugin` on `path`, with the object's paints as `given`: geometry, or for an effect -/// that declares `paintOutput` its pieces, checked for an effect in `scope`. -fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given, scope: Scope) -> Result { +/// One run of `plugin` on `path` (reference box and fill rule `(bounds, rule)`), with the object's +/// paints as `given`, stopped after `wall_time_ms`: geometry, or for an effect that declares +/// `paintOutput` its pieces, checked for an effect in `scope`. +fn run( + plugin: &crate::Plugin, + mut params: Value, + path: &PathData, + (bounds, rule): (Rect, FillRule), + given: Given, + scope: Scope, + wall_time_ms: u64, +) -> Result { let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]); params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item}); let rule_name = match rule { @@ -404,7 +498,9 @@ fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, json!({"objects": [{"type": "path", "path": path, "fillRule": rule_name, "bounds": b, "fills": given.fills, "strokes": given.strokes}]}); let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(¶ms)); let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) }; - let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }; + let limits = Limits { wall_time_ms, ..plugin.limits().clone() }; + #[cfg(test)] + tests::RUNS_STARTED.with(|n| n.set(n.get() + 1)); let output = plugin.run_limited(&input, ¶ms, &limits)?; if plugin.manifest().paint_output { return Ok(Out::Pieces(Arc::new(painted::decode(&output, scope, rule)?))); @@ -420,6 +516,11 @@ mod tests { use super::*; + thread_local! { + /// How many plug-in runs this thread started ([`run`]). + pub(super) static RUNS_STARTED: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + /// Fills and strokes come in paint order, and the item an effect sits on is counted among the /// fills or among the strokes. #[test] @@ -427,7 +528,7 @@ mod tests { let fill = |c| AppearanceItem::Fill(FillLayer::new(Paint::solid(c))); let stroke = |w| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), w)); let ap = Appearance { items: vec![fill(Color::WHITE), stroke(2.0), fill(Color::BLACK), stroke(5.0)], ..Appearance::default() }; - let given = |item| Paints { appearance: Some(&ap), item }.given(); + let given = |item| Paints { appearance: Some(&ap), item, at: None }.given(); let g = given(None); assert_eq!((g.fills.len(), g.strokes.len(), &g.item), (2, 2, &json!("object"))); assert_eq!(g.strokes.iter().map(|s| s["width"].clone()).collect::>(), [json!(2.0), json!(5.0)]); @@ -494,9 +595,141 @@ mod tests { assert!(last_error(ID).unwrap().contains("trapped")); assert!(draw(&short).is_none(), "from the cache, refused again"); assert!(last_error(ID).unwrap().contains("swatch")); + // Inside a document's view, the refused stage is deferred as failed, also from the cache. + let view = crate::deferred::Deferrals::with_clock(Arc::new(|| Some(0))); + let at = Stage { object: 3, effect: 1, item: None }; + let in_view = || { + view.view(|| { + apply_painted( + ID, + &json!({}), + &short, + Rect::new(0.0, 0.0, 1.0, 1.0), + FillRule::NonZero, + Paints { at: Some(at), ..Paints::default() }, + Some(&doc), + ) + }) + }; + assert!(in_view().is_none()); + let listed = || view.list().iter().map(|d| (d.stage, d.reason)).collect::>(); + assert_eq!(listed(), [(at, Reason::Failed)]); + view.first_view_done(); + assert!(in_view().is_none()); + assert_eq!(listed(), [(at, Reason::Failed)], "a later drawing leaves it listed"); registry::remove(ID); } + /// A clock that reads `step` milliseconds later each time it is read. + fn stepping(step: u64) -> crate::deferred::Clock { + let t = Arc::new(std::sync::atomic::AtomicU64::new(0)); + Arc::new(move || Some(t.fetch_add(step, std::sync::atomic::Ordering::Relaxed))) + } + + /// A line of `n` points: a distinct input for each `n`. + fn line(n: usize) -> PathData { + PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::>(), false)) + } + + /// Plug-in `id` drawn on `path` as effect `effect` of object 1: whether it gave a result. + fn drawn(id: &str, path: &PathData, effect: usize) -> bool { + let at = Some(Stage { object: 1, effect, item: None }); + apply(id, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints { at, ..Paints::default() }, None).is_some() + } + + /// The effects of a first view share its budget: once it is spent no run starts, and a result + /// that comes back after it ran out is not used. Later drawings leave the deferred stages + /// deferred; a drawing outside the view (an export) runs them, and the view then uses the + /// result. + #[test] + fn the_first_view_shares_the_budget_and_defers_the_rest() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.budget"; + let src = crate::wat::returning(&crate::wat::manifest(ID, "effect", "{}"), r#"{"objects":[]}"#); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + // Each run takes 900 ms by this clock (it is read before and after each run). + let view = crate::deferred::Deferrals::with_clock(stepping(450)); + let ran = view.view(|| (0..4).map(|i| drawn(ID, &line(i + 2), i)).collect::>()); + assert_eq!(ran, [true, true, false, false], "the third result comes back at 2,250 ms, the fourth doesn't start"); + let listed = view.list().iter().map(|d| (d.stage.effect, d.reason, d.plugin.clone())).collect::>(); + assert_eq!(listed, [(2, Reason::Budget, ID.to_string()), (3, Reason::Budget, ID.to_string())]); + view.first_view_done(); + assert_eq!(view.take_report(), Some(2)); + assert!(!view.view(|| drawn(ID, &line(4), 2)), "a later drawing leaves it deferred"); + assert!(view.view(|| drawn(ID, &line(9), 3)), "an edit to its input runs it"); + assert_eq!(view.list().len(), 1); + assert!(drawn(ID, &line(4), 2), "outside the view it runs"); + assert_eq!(view.list().len(), 1, "until the view draws it again"); + assert!(view.view(|| drawn(ID, &line(9), 2)), "an edit to an input whose result is cached"); + assert!(view.list().is_empty(), "the record of the input it no longer has goes"); + assert!(view.view(|| drawn(ID, &line(4), 2)), "from the cache"); + assert!(view.list().is_empty()); + registry::remove(ID); + } + + /// A stage that fails, or whose plug-in is missing, is deferred with that reason, from a run or + /// from the cache; a failure is cached and reported as before. + #[test] + fn failed_and_missing_stages_are_deferred_with_their_reason() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.deferfailed"; + // Runs to its instruction budget. + let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + let view = crate::deferred::Deferrals::with_clock(stepping(1)); + assert!(!view.view(|| drawn(ID, &line(2), 0))); + assert!(last_error(ID).is_some()); + assert!(!view.view(|| drawn("test.effect.notinstalled", &line(2), 1))); + let reasons = |v: &crate::deferred::Deferrals| v.list().iter().map(|d| d.reason).collect::>(); + assert_eq!(reasons(&view), [Reason::Failed, Reason::Missing]); + let other = crate::deferred::Deferrals::with_clock(stepping(1)); + assert!(!other.view(|| drawn(ID, &line(2), 0))); + assert_eq!(reasons(&other), [Reason::Failed], "from the cache, in another document"); + // A refresh runs a stage whose failure is cached again, once, and later drawings take the + // failure from the cache. + other.first_view_done(); + let runs = || RUNS_STARTED.with(std::cell::Cell::get); + let before = runs(); + let counts = other.refresh(None, |_| { + other.view(|| (drawn(ID, &line(2), 0), drawn(ID, &line(2), 0))); + vec![] + }); + assert_eq!((counts.failed, runs() - before), (1, 1)); + assert!(!other.view(|| drawn(ID, &line(2), 0))); + assert_eq!((reasons(&other), runs() - before), (vec![Reason::Failed], 1)); + registry::remove(ID); + } + + /// A run the budget cuts short is deferred for want of time: it is not cached, and the plug-in + /// reports no error. + #[test] + fn a_run_the_budget_cuts_short_is_deferred_for_time() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.cutshort"; + const FAST: &str = "test.effect.cutshort.fast"; + let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 10_000_000, r#"{"objects":[]}"#); + let limits = Limits { fuel_base: 1_000_000_000, ..Limits::default() }; + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap()); + let fast = crate::wat::returning(&crate::wat::manifest(FAST, "effect", "{}"), r#"{"objects":[]}"#); + registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap()); + // The first run starts the budget at 0 ms and ends at 1,999: the next has 1 ms left, and + // ten million steps take longer. + let reads = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let clock: crate::deferred::Clock = + Arc::new(move || Some(if reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 0 { 0 } else { 1_999 })); + let view = crate::deferred::Deferrals::with_clock(clock); + assert!(view.view(|| drawn(FAST, &line(2), 0))); + assert!(!view.view(|| drawn(ID, &line(2), 1))); + assert_eq!(view.list().iter().map(|d| (d.stage.effect, d.reason)).collect::>(), [(1, Reason::Budget)]); + assert_eq!(last_error(ID), None); + assert!(drawn(ID, &line(2), 1), "not cached: outside the view it runs to the end"); + registry::remove(ID); + registry::remove(FAST); + } + /// A first run that is cancelled leaves the next run to be the first: the launch marker names /// the plug-in during that one. #[test] diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index 182456aa7..474234880 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -318,6 +318,9 @@ pub struct Report { pub truncated: bool, } +/// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]). +pub(crate) const TIME_BUDGET: &str = "time budget"; + /// When a run must stop (native: a wall-clock deadline). #[derive(Clone, Copy)] pub(crate) struct Deadline { @@ -344,7 +347,7 @@ impl Deadline { } pub(crate) fn check(&self) -> Result<()> { - if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) } + if self.passed() { Err(Error::Limit(TIME_BUDGET.into())) } else { Ok(()) } } } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index b4faf543d..63f879c2f 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -34,6 +34,7 @@ //! runs through `vc_run` with the command's id in `_context`. #![forbid(unsafe_code)] +pub mod deferred; pub mod dialog; pub mod effect; pub mod export; diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 4092de4e4..38b790ecc 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -123,9 +123,9 @@ pub struct CacheKey { /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. pub placed: u64, /// The plug-in revision ([`vectorcraft_render::effects::plugin_revision`]) of the last plug-in - /// install or removal after which the document drew a plug-in effect - /// ([`vectorcraft_render::draws_plugin_effects`]): a frame drawn before it draws those effects - /// as the plug-ins installed then did. 0 until then. + /// install or removal, or of the last time deferred plug-in effect stages gave results, after + /// which the document drew a plug-in effect ([`vectorcraft_render::draws_plugin_effects`]): a + /// frame drawn before it draws those effects as the plug-ins installed then did. 0 until then. pub plugins: u64, /// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown /// with hard edges. None: the art is rendered for the screen. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 63be246da..56f121ad6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -293,6 +293,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); + let effects = st.plugin_effects.clone(); let mask_view = st.shown_mask(); paint_paper(app, st, ui, &painter, &xf, rect, (&t, app.outline_view())); @@ -349,8 +350,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) { let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); let opts = frame_options(app, &key, mask_view, smooth_images); - draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel }); + draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel }); } + report_deferred(app, &effects); // The status bar reports each image that a renderer (this thread's or the worker's) could not // decode, once per image in the process. let undecoded = vectorcraft_render::newly_undecodable(doc.images.values()); @@ -606,7 +608,7 @@ fn cache_key( /// [`CacheKey::plugins`] of document `st`, one of the documents open in `session`. `keys` holds each /// document's value and the plug-in revision it was checked at, so a document is looked through only -/// once after each plug-in install or removal. +/// once after each change of the plug-in revision. fn plugin_key( keys: &mut std::collections::HashMap, session: &vectorcraft_engine::Session, @@ -624,6 +626,19 @@ fn plugin_key( } } +/// Once the first view of a document has been drawn, the status bar says how many plug-in effect +/// stages it left deferred, if any. +fn report_deferred(app: &mut VectorcraftApp, effects: &vectorcraft_plugins::deferred::Deferrals) { + if let Some(n) = effects.take_report().filter(|n| *n > 0) { + app.status(crate::i18n::msg_n( + n as u64, + "Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.", + "Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them.", + &[], + )); + } +} + /// Start the render worker, and upload the frame it finished. fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) { if !app.canvas.worker_started { @@ -647,6 +662,9 @@ fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate: return; } upload(app, ctx, done.img, done.key.pixel.is_some()); + if let Some(d) = app.session.documents().iter().find(|d| d.uid as usize == done.key.doc) { + d.plugin_effects.first_view_done(); + } app.canvas.key = Some(done.key); app.canvas.shown = Some((done.doc, done.opts)); app.perf.render_ms = done.ms; @@ -729,6 +747,8 @@ fn frame_options( struct Frame<'a> { key: &'a CacheKey, doc: &'a std::sync::Arc, + /// The document's deferred plug-in effect stages: the frame renders inside their view. + effects: &'a vectorcraft_plugins::deferred::Deferrals, size: (u32, u32), view: Affine, opts: vectorcraft_render::RenderOptions, @@ -740,7 +760,7 @@ struct Frame<'a> { pub(crate) const FIRST_FRAME_GRACE: std::time::Duration = std::time::Duration::from_millis(16); fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { - let Frame { key, doc, size: (w, h), view, opts, pixel } = frame; + let Frame { key, doc, effects, size: (w, h), view, opts, pixel } = frame; // Frames that would be slow on this thread (the last one took over 8 ms) go // to the worker. An interaction's frames stay on the worker once it drew the frame shown: // its renderer keeps the rasters of the art's effects that make those frames fast, and this @@ -768,7 +788,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { app.canvas.renderer.let_go_of_measured(); } if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { - let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); + let renderer = &mut app.canvas.renderer; + let part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); let color = color_image(&part); let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; tex.set_partial([x0 as usize, y0 as usize], color, options); @@ -790,6 +811,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { view, opts, scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects: effects.clone(), may_copy, may_keep: true, }); @@ -801,7 +823,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } _ => { let t0 = now_ms(); - let img = app.canvas.renderer.render(doc, w, h, view, &opts); + let renderer = &mut app.canvas.renderer; + let img = effects.view(|| renderer.render(doc, w, h, view, &opts)); + effects.first_view_done(); upload(app, ui.ctx(), img, pixel.is_some()); app.canvas.key = Some(key.clone()); app.canvas.shown = Some((doc.clone(), opts.clone())); diff --git a/crates/ui-egui/src/canvas/fill.rs b/crates/ui-egui/src/canvas/fill.rs index 60d8b0075..8eda1db21 100644 --- a/crates/ui-egui/src/canvas/fill.rs +++ b/crates/ui-egui/src/canvas/fill.rs @@ -95,6 +95,7 @@ pub(super) fn fill_uncovered( let (_, _, view) = fill_view(&done.key); app.canvas.fill = Some(Fill { key: done.key, view, size: (fw, fh), tex }); } + let effects = app.session.documents().iter().find(|d| d.uid as usize == key.doc).map(|d| d.plugin_effects.clone()).unwrap_or_default(); let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return }; if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) { let (w, h, view) = fill_view(key); @@ -106,6 +107,7 @@ pub(super) fn fill_uncovered( view, opts, scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects, may_copy: false, may_keep: false, }); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index a9ef2c228..587b4ced1 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2395,6 +2395,52 @@ fn undecodable_images_are_marked_and_reported_once() { assert_eq!(app.ui.status, "", "said once"); } +/// The plug-in effects a document's first view leaves out past its budget are reported in the +/// status bar once, in the singular or the plural, and Effect › Refresh Deferred Effects is +/// enabled until it has run them. +#[test] +fn the_first_view_reports_the_plug_in_effects_it_deferred() { + use std::sync::atomic::{AtomicU64, Ordering}; + use vectorcraft_plugins::wat as tpl; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.canvas.worker_started = true; // render on this thread + let module = tpl::module(&tpl::manifest("test.ui.deferred", "effect", "{}"), tpl::ECHO, "", 1); + app.session.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(module).unwrap())})).unwrap(); + // Each reading of the clock is 3 s later: the first run's result comes back after the budget, + // and no later run starts. + let step = std::sync::Arc::new(AtomicU64::new(3_000)); + let ctx = egui::Context::default(); + for (count, said) in [ + (2, "Opened with 2 plug-in effects deferred. Effect › Refresh Deferred Effects runs them."), + (1, "Opened with 1 plug-in effect deferred. Effect › Refresh Deferred Effects runs it."), + ] { + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Art the plug-in has not been given before: a result it gave is kept and drawn at once. + for i in 0..count { + let x = 31.0 + 97.0 * f64::from(i) + 13.0 * f64::from(count); + let id = app.session.execute("shape.rectangle", &json!({"x": x, "y": 47, "width": 23, "height": 29})).unwrap()["id"].clone(); + app.session.execute("effect.apply", &json!({"ids": [id], "effect": "plugin.test.ui.deferred"})).unwrap(); + } + app.session.execute("select.none", &json!({})).unwrap(); + let (now, by) = (std::sync::Arc::new(AtomicU64::new(0)), step.clone()); + let clock: vectorcraft_plugins::deferred::Clock = + std::sync::Arc::new(move || Some(now.fetch_add(by.load(Ordering::Relaxed), Ordering::Relaxed))); + app.session.doc_mut().unwrap().plugin_effects = vectorcraft_plugins::deferred::Deferrals::with_clock(clock); + step.store(3_000, Ordering::Relaxed); + app.status(""); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.status, said); + assert!(crate::menus::enabled(&app, "effect.refreshDeferred")); + app.status(""); + app.session.doc_mut().unwrap().revision += 1; + frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.status, "", "said once"); + step.store(0, Ordering::Relaxed); + app.session.execute("effect.refreshDeferred", &json!({})).unwrap(); + assert!(!crate::menus::enabled(&app, "effect.refreshDeferred"), "nothing is left deferred"); + } +} + /// A drag of an object with raster effects (#1257): the drag's frames render while /// interacting and draw the object's drop shadow and filtered content from the rasters of the /// frame before; the frame after the drag renders without `interacting`, on the render worker, @@ -10687,6 +10733,7 @@ fn pan_job(doc: &std::sync::Arc, key: &CacheKey, (dx, view: Affine::translate((dx, dy)), opts: vectorcraft_render::RenderOptions::default(), scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects: Default::default(), may_copy: true, may_keep: true, } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ee32cee33..6e7750058 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6504,3 +6504,7 @@ breakText Glyphs Глифи Ended by an internal fault Прекратен поради вътрешна грешка {ms} ms, {calls} host calls {ms} ms, {calls} извиквания към хоста {dropped} earlier lines dropped, {cut} lines cut {dropped} по-ранни реда премахнати, {cut} реда съкратени + +# Deferred plug-in effects + Refresh Deferred Effects Обновяване на отложените ефекти + Deferred Effects Отложени ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 0970a7092..b5d50e03f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6555,3 +6555,7 @@ shape Open Otvoren Ended by an internal fault Završeno zbog interne greške {ms} ms, {calls} host calls {ms} ms, {calls} poziva hosta {dropped} earlier lines dropped, {cut} lines cut {dropped} ranijih redova uklonjeno, {cut} redova skraćeno + +# Deferred plug-in effects + Refresh Deferred Effects Osvježi odgođene efekte + Deferred Effects Odgođeni efekti diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 84b6833c4..fad1dc153 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6759,3 +6759,9 @@ breakText Glyphs Glifs {ms} ms, {calls} host calls {ms} ms, {calls} crides a l’amfitrió {dropped} earlier lines dropped, {cut} lines cut {dropped} línies anteriors descartades, {cut} línies tallades @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last` + +# Deferred plug-in effects + Refresh Deferred Effects Actualitza els efectes ajornats + Deferred Effects Efectes ajornats +@msg no plug-in effect is deferred no hi ha cap efecte de complement ajornat +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. S’ha obert amb {n} efecte de complement ajornat. Efecte › Actualitza els efectes ajornats l’executa.|S’ha obert amb {n} efectes de complement ajornats. Efecte › Actualitza els efectes ajornats els executa. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 733a3e813..11617a306 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6778,3 +6778,9 @@ breakText Glyphs Glyfy {ms} ms, {calls} host calls {ms} ms, {calls} volání hostitele {dropped} earlier lines dropped, {cut} lines cut {dropped} starších řádků zahozeno, {cut} řádků zkráceno @msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last` + +# Deferred plug-in effects + Refresh Deferred Effects Obnovit odložené efekty + Deferred Effects Odložené efekty +@msg no plug-in effect is deferred žádný efekt zásuvného modulu není odložen +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otevřeno s {n} odloženým efektem zásuvného modulu. Efekt › Obnovit odložené efekty jej spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index d1871b2ed..90325507f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6518,3 +6518,7 @@ breakText Glyphs Glyffer Ended by an internal fault Afsluttet af en intern fejl {ms} ms, {calls} host calls {ms} ms, {calls} værtskald {dropped} earlier lines dropped, {cut} lines cut {dropped} tidligere linjer fjernet, {cut} linjer afkortet + +# Deferred plug-in effects + Refresh Deferred Effects Opdatér udskudte effekter + Deferred Effects Udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8c75b25b6..4931ef208 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6365,3 +6365,9 @@ breakText Glyphs Glyphen {ms} ms, {calls} host calls {ms} ms, {calls} Host-Aufrufe {dropped} earlier lines dropped, {cut} lines cut {dropped} ältere Zeilen verworfen, {cut} Zeilen gekürzt @msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last` + +# Deferred plug-in effects + Refresh Deferred Effects Zurückgestellte Effekte aktualisieren + Deferred Effects Zurückgestellte Effekte +@msg no plug-in effect is deferred kein Plug-in-Effekt ist zurückgestellt +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Mit {n} zurückgestelltem Plug-in-Effekt geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt ihn aus.|Mit {n} zurückgestellten Plug-in-Effekten geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt sie aus. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7813ee6cf..53729616f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6730,3 +6730,9 @@ breakText Glyphs Γλύφοι {ms} ms, {calls} host calls {ms} ms, {calls} κλήσεις προς το κεντρικό σύστημα {dropped} earlier lines dropped, {cut} lines cut {dropped} παλαιότερες γραμμές απορρίφθηκαν, {cut} γραμμές περικόπηκαν @msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last` + +# Deferred plug-in effects + Refresh Deferred Effects Ανανέωση εφέ σε αναμονή + Deferred Effects Εφέ σε αναμονή +@msg no plug-in effect is deferred κανένα εφέ προσθέτου δεν είναι σε αναμονή +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Άνοιξε με {n} εφέ προσθέτου σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή το εκτελεί.|Άνοιξε με {n} εφέ προσθέτων σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή τα εκτελεί. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d8bcecbc6..e70daac85 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6721,3 +6721,9 @@ breakText Glyphs Glifos {ms} ms, {calls} host calls {ms} ms, {calls} llamadas al host {dropped} earlier lines dropped, {cut} lines cut {dropped} líneas anteriores descartadas, {cut} líneas recortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last` + +# Deferred plug-in effects + Refresh Deferred Effects Actualizar efectos aplazados + Deferred Effects Efectos aplazados +@msg no plug-in effect is deferred no hay ningún efecto de plugin aplazado +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Se abrió con {n} efecto de plugin aplazado. Efecto › Actualizar efectos aplazados lo ejecuta.|Se abrió con {n} efectos de plugin aplazados. Efecto › Actualizar efectos aplazados los ejecuta. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d3b9fb326..3969a4167 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6813,3 +6813,9 @@ breakText Glyphs Merkit {ms} ms, {calls} host calls {ms} ms, {calls} isäntäkutsua {dropped} earlier lines dropped, {cut} lines cut {dropped} aiempaa riviä poistettu, {cut} riviä katkaistu @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last` + +# Deferred plug-in effects + Refresh Deferred Effects Päivitä lykätyt tehosteet + Deferred Effects Lykätyt tehosteet +@msg no plug-in effect is deferred yhtään laajennustehostetta ei ole lykätty +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Avattiin, ja {n} laajennustehoste lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa sen.|Avattiin, ja {n} laajennustehostetta lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa ne. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6db506204..8ffa30e49 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6773,3 +6773,9 @@ breakText Glyphs Glyphes {ms} ms, {calls} host calls {ms} ms, {calls} appels à l’hôte {dropped} earlier lines dropped, {cut} lines cut {dropped} lignes antérieures supprimées, {cut} lignes coupées @msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last` + +# Deferred plug-in effects + Refresh Deferred Effects Actualiser les effets différés + Deferred Effects Effets différés +@msg no plug-in effect is deferred aucun effet de plug-in n’est différé +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Ouvert avec {n} effet de plug-in différé. Effet › Actualiser les effets différés l’exécute.|Ouvert avec {n} effets de plug-in différés. Effet › Actualiser les effets différés les exécute. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3fa226908..5e60e4a77 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8219,3 +8219,9 @@ theme Appearance अपीयरेंस {ms} ms, {calls} host calls {ms} ms, {calls} होस्ट कॉल {dropped} earlier lines dropped, {cut} lines cut {dropped} पुरानी पंक्तियाँ हटाई गईं, {cut} पंक्तियाँ काटी गईं @msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में + +# Deferred plug-in effects + Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें + Deferred Effects स्थगित इफ़ेक्ट्स +@msg no plug-in effect is deferred कोई प्लग-इन इफ़ेक्ट स्थगित नहीं है +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} प्लग-इन इफ़ेक्ट स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इसे चलाता है।|{n} प्लग-इन इफ़ेक्ट्स स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इन्हें चलाता है। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27e22d0ad..854403c8a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6762,3 +6762,9 @@ breakText Glyphs Glifák {ms} ms, {calls} host calls {ms} ms, {calls} gazdahívás {dropped} earlier lines dropped, {cut} lines cut {dropped} korábbi sor elvetve, {cut} sor levágva @msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt + +# Deferred plug-in effects + Refresh Deferred Effects Elhalasztott effektusok frissítése + Deferred Effects Elhalasztott effektusok +@msg no plug-in effect is deferred nincs elhalasztott bővítményeffektus +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Megnyitva, elhalasztott bővítményeffektusok: {n}. Az Effektus › Elhalasztott effektusok frissítése futtatja őket. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c4e97482a..5148ae16e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6518,3 +6518,7 @@ breakText Glyphs Glif Ended by an internal fault Berakhir karena galat internal {ms} ms, {calls} host calls {ms} ms, {calls} panggilan host {dropped} earlier lines dropped, {cut} lines cut {dropped} baris lama dibuang, {cut} baris dipotong + +# Deferred plug-in effects + Refresh Deferred Effects Segarkan efek yang ditunda + Deferred Effects Efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 987505a43..5e33fa9a0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6399,3 +6399,9 @@ breakText Glyphs Glifi {ms} ms, {calls} host calls {ms} ms, {calls} chiamate all'host {dropped} earlier lines dropped, {cut} lines cut {dropped} righe precedenti scartate, {cut} righe tagliate @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last` + +# Deferred plug-in effects + Refresh Deferred Effects Aggiorna effetti rimandati + Deferred Effects Effetti rimandati +@msg no plug-in effect is deferred nessun effetto plug-in è rimandato +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aperto con {n} effetto plug-in rimandato. Effetto › Aggiorna effetti rimandati lo esegue.|Aperto con {n} effetti plug-in rimandati. Effetto › Aggiorna effetti rimandati li esegue. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d3c0b6603..2791e7b83 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6464,3 +6464,9 @@ breakText Glyphs 字形 {ms} ms, {calls} host calls {ms} ms、ホスト呼び出し {calls} 回 {dropped} earlier lines dropped, {cut} lines cut 古い行 {dropped} 行を破棄、{cut} 行を切り詰め @msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください + +# Deferred plug-in effects + Refresh Deferred Effects 保留中の効果を更新 + Deferred Effects 保留中の効果 +@msg no plug-in effect is deferred 保留中のプラグイン効果はありません +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. プラグイン効果を {n} 件保留して開きました。「効果 › 保留中の効果を更新」で実行します。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5a05ccec3..046d43e11 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6839,3 +6839,9 @@ font-script Japanese იაპონური {ms} ms, {calls} host calls {ms} ms, ჰოსტის {calls} გამოძახება {dropped} earlier lines dropped, {cut} lines cut {dropped} ძველი სტრიქონი წაიშალა, {cut} სტრიქონი შემოკლდა @msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ + +# Deferred plug-in effects + Refresh Deferred Effects გადადებული ეფექტების განახლება + Deferred Effects გადადებული ეფექტები +@msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b0fc26d1f..f979865be 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6360,3 +6360,9 @@ breakText Glyphs 글리프 {ms} ms, {calls} host calls {ms} ms, 호스트 호출 {calls}회 {dropped} earlier lines dropped, {cut} lines cut 이전 줄 {dropped}개 삭제됨, 줄 {cut}개 잘림 @msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다 + +# Deferred plug-in effects + Refresh Deferred Effects 보류된 효과 새로 고침 + Deferred Effects 보류된 효과 +@msg no plug-in effect is deferred 보류된 플러그인 효과가 없습니다 +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 플러그인 효과 {n}개를 보류하고 열었습니다. 효과 › 보류된 효과 새로 고침으로 실행합니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c30a50e71..5b429c088 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6569,3 +6569,7 @@ breakText Glyphs Glyphs Ended by an internal fault Beëindigd door een interne fout {ms} ms, {calls} host calls {ms} ms, {calls} host-aanroepen {dropped} earlier lines dropped, {cut} lines cut {dropped} eerdere regels verwijderd, {cut} regels ingekort + +# Deferred plug-in effects + Refresh Deferred Effects Uitgestelde effecten vernieuwen + Deferred Effects Uitgestelde effecten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8450cb654..cfcf8767d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6634,3 +6634,9 @@ breakText Glyphs Glify {ms} ms, {calls} host calls {ms} ms, wywołania hosta: {calls} {dropped} earlier lines dropped, {cut} lines cut Odrzucone wcześniejsze wiersze: {dropped}, skrócone wiersze: {cut} @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last` + +# Deferred plug-in effects + Refresh Deferred Effects Odśwież odroczone efekty + Deferred Effects Odroczone efekty +@msg no plug-in effect is deferred żaden efekt wtyczki nie jest odroczony +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otwarto z {n} odroczonym efektem wtyczki. Efekt › Odśwież odroczone efekty go uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 00d9a8948..32606233f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6851,3 +6851,9 @@ swatch Yellow Green Verde-amarelado {ms} ms, {calls} host calls {ms} ms, {calls} chamadas ao host {dropped} earlier lines dropped, {cut} lines cut {dropped} linhas anteriores descartadas, {cut} linhas cortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last` + +# Deferred plug-in effects + Refresh Deferred Effects Atualizar Efeitos Adiados + Deferred Effects Efeitos Adiados +@msg no plug-in effect is deferred nenhum efeito de plug-in está adiado +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aberto com {n} efeito de plug-in adiado. Efeito › Atualizar Efeitos Adiados o executa.|Aberto com {n} efeitos de plug-in adiados. Efeito › Atualizar Efeitos Adiados os executa. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 24a6c74d5..94f6b36b0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6867,3 +6867,9 @@ shape Open Открытый {ms} ms, {calls} host calls {ms} мс, вызовов хоста: {calls} {dropped} earlier lines dropped, {cut} lines cut Отброшено прежних строк: {dropped}, обрезано строк: {cut} @msg `first` and `last` must be numbers of lines the console holds, first before last `first` и `last` должны быть номерами строк, которые хранит консоль, причём `first` не позже `last` + +# Deferred plug-in effects + Refresh Deferred Effects Обновить отложенные эффекты + Deferred Effects Отложенные эффекты +@msg no plug-in effect is deferred нет отложенных эффектов модулей +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Открыто с {n} отложенным эффектом модуля. Эффект › Обновить отложенные эффекты выполнит его.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c038612c1..232b13f9b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8263,3 +8263,9 @@ digits Hindi Hindi {ms} ms, {calls} host calls {ms} ms, {calls} värdanrop {dropped} earlier lines dropped, {cut} lines cut {dropped} tidigare rader borttagna, {cut} rader avkortade @msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last` + +# Deferred plug-in effects + Refresh Deferred Effects Uppdatera uppskjutna effekter + Deferred Effects Uppskjutna effekter +@msg no plug-in effect is deferred ingen plugineffekt är uppskjuten +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Öppnades med {n} uppskjuten plugineffekt. Effekt › Uppdatera uppskjutna effekter kör den.|Öppnades med {n} uppskjutna plugineffekter. Effekt › Uppdatera uppskjutna effekter kör dem. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 282990771..bda6a433a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6734,3 +6734,9 @@ breakText Glyphs Glifler {ms} ms, {calls} host calls {ms} ms, {calls} ana bilgisayar çağrısı {dropped} earlier lines dropped, {cut} lines cut {dropped} eski satır atıldı, {cut} satır kesildi @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli + +# Deferred plug-in effects + Refresh Deferred Effects Ertelenen Efektleri Yenile + Deferred Effects Ertelenen Efektler +@msg no plug-in effect is deferred ertelenen eklenti efekti yok +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} eklenti efekti ertelenerek açıldı. Efekt › Ertelenen Efektleri Yenile bunları çalıştırır. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 88bcb4702..3f1b2edef 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6379,3 +6379,9 @@ breakText Glyphs Гліфи {ms} ms, {calls} host calls {ms} мс, викликів хоста: {calls} {dropped} earlier lines dropped, {cut} lines cut Відкинуто попередніх рядків: {dropped}, обрізано рядків: {cut} @msg `first` and `last` must be numbers of lines the console holds, first before last `first` і `last` мають бути номерами рядків, які зберігає консоль, причому `first` не пізніше `last` + +# Deferred plug-in effects + Refresh Deferred Effects Оновити відкладені ефекти + Deferred Effects Відкладені ефекти +@msg no plug-in effect is deferred немає відкладених ефектів модулів +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Відкрито з {n} відкладеним ефектом модуля. Ефект › Оновити відкладені ефекти виконає його.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 90c292bc9..f7c86e4ef 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6380,3 +6380,9 @@ breakText Glyphs Glyph {ms} ms, {calls} host calls {ms} ms, {calls} lệnh gọi máy chủ {dropped} earlier lines dropped, {cut} lines cut Đã bỏ {dropped} dòng cũ, đã cắt {cut} dòng @msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last` + +# Deferred plug-in effects + Refresh Deferred Effects Làm mới hiệu ứng bị hoãn + Deferred Effects Hiệu ứng bị hoãn +@msg no plug-in effect is deferred không có hiệu ứng plug-in nào bị hoãn +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Đã mở với {n} hiệu ứng plug-in bị hoãn. Hiệu ứng › Làm mới hiệu ứng bị hoãn sẽ chạy chúng. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 77578eec1..f7e5247ae 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6616,3 +6616,9 @@ breakText Glyphs 字形 {ms} ms, {calls} host calls {ms} 毫秒,宿主调用 {calls} 次 {dropped} earlier lines dropped, {cut} lines cut 已丢弃 {dropped} 行较早内容,已截断 {cut} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后 + +# Deferred plug-in effects + Refresh Deferred Effects 刷新延迟的效果 + Deferred Effects 延迟的效果 +@msg no plug-in effect is deferred 没有延迟的增效工具效果 +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 打开时延迟了 {n} 个增效工具效果。效果 › 刷新延迟的效果可运行它们。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bdec63590..66d744f10 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6414,3 +6414,9 @@ breakText Glyphs 字符 {ms} ms, {calls} host calls {ms} 毫秒,主機呼叫 {calls} 次 {dropped} earlier lines dropped, {cut} lines cut 已捨棄 {dropped} 行較早內容,已截斷 {cut} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後 + +# Deferred plug-in effects + Refresh Deferred Effects 重新整理延後的效果 + Deferred Effects 延後的效果 +@msg no plug-in effect is deferred 沒有延後的增效模組效果 +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 開啟時延後了 {n} 個增效模組效果。效果 › 重新整理延後的效果可執行它們。 diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs index 9e8a7ac65..469c40e7c 100644 --- a/crates/ui-egui/src/menus/submenus.rs +++ b/crates/ui-egui/src/menus/submenus.rs @@ -136,6 +136,7 @@ pub(super) fn effect_menu() -> Vec { c("Last Effect…", "effect.last"), Sep, c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), + c("Refresh Deferred Effects", "effect.refreshDeferred"), Sep, Item::Header("Vector Effects"), ]; diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index bf764e0e4..40478e36e 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -24,6 +24,9 @@ pub struct Job { /// The automation roots in force where the job was made: the render reads linked files under /// them, as it would on that thread. pub scope: ScopeHandle, + /// The document's deferred live-effect plug-in stages and first-view budget: the render runs + /// inside their view ([`vectorcraft_plugins::deferred::Deferrals::view`]). + pub effects: vectorcraft_plugins::deferred::Deferrals, /// The render may copy pixels from the worker's last frame ([`crate::canvas::render_job`]). /// Off: it renders the whole frame. pub may_copy: bool, @@ -71,7 +74,7 @@ impl Worker { job = newer; } let t0 = crate::now_ms(); - let frame = job.scope.clone().run(|| crate::canvas::render_job(&mut r, &mut kept, &job)); + let frame = job.scope.clone().run(|| job.effects.view(|| crate::canvas::render_job(&mut r, &mut kept, &job))); let ms = crate::now_ms() - t0; let done = Done { key: job.key, doc: job.doc, opts: job.opts, img: frame.img, ms, moved: frame.moved, reused: frame.reused }; if done_tx.send(done).is_err() { diff --git a/docs/mcp.md b/docs/mcp.md index 19f16205c..94a29ad56 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7134,7 +7134,26 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc `effect.list` lists it. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api` and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them as ordinary paths with those paints; a result it refuses is drawn as if the effect weren't there, and `plugin.info`'s -`lastError` names the plug-in and the refused field. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): +`lastError` names the plug-in and the refused field. While the app draws a document on screen, the live-effect plug-ins +of its first view share a 2,000 ms budget (VectorCraft's own value), and each run also keeps to its own 5,000 ms limit: +once the budget is spent no run starts, and a result that comes back after it is not used. A stage left out for want +of time, or one that times out, fails or has no plug-in, draws its input art unchanged and is deferred; the status bar +then says how many (`Opened with 3 plug-in effects deferred. Effect › Refresh Deferred Effects runs them.`). Later +drawings leave a deferred stage as it is until its input or parameters change, which runs it under the per-call limit. +`effect.deferredStatus {}` returns `{budgetMs: 2000, effects: [{objectId, effectIndex, item?, pluginId, reason}]}` for +the active document (`reason`: `budget`, `timeout`, `missing`, `failed` or `safeMode`; `item` is the fill or stroke +whose effect it is; `objectId` can be an object in the art of a symbol, pattern, brush or opacity mask; an empty list +when none is deferred). A stage whose object is deleted, or whose plug-in effect is +removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?}` (Effect › Refresh +Deferred Effects, enabled while a stage is deferred) runs the deferred stages of those objects (default: all of them) +again with a fresh 2,000 ms budget and returns `{refreshed, stillDeferred, failed}`: `stillDeferred` counts stages left +out for want of time again, the stages of an object that is hidden or in a hidden group or layer, which is not drawn, +and the stages with a part of their input too small for the drawing the refresh makes, and `failed` those that +timed out, failed or have no plug-in. `id` names one object in place of +`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand +Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only +deferred for time, and the canvas then draws the result. The web build has no clock for the budget: there the first +view runs no plug-in effect, and Refresh Deferred Effects runs them. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?: diff --git a/docs/plugins.md b/docs/plugins.md index 026dcc0d9..55de6cdaf 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -137,6 +137,19 @@ An export runs where the export runs: with Background Export on, on the export's plug-in between two of those 20 M-instruction slices; nothing is written then. An import stops the same way when the open it runs in is cancelled, and nothing opens then. +### Live effects while a document opens + +While the app draws a document on screen, the live-effect runs of its first view share a 2,000 ms budget +(`vectorcraft_plugins::deferred::OPENING_BUDGET_MS`, VectorCraft's own value), measured from the first run. Each run +takes the lesser of its 5 s limit and what is left of the budget; once the budget is spent no run starts, and a result +that comes back after it is not used. A stage left out this way, or one that times out, traps, returns output the host +refuses or has no plug-in, draws its input art unchanged and is deferred. The status bar then says how many stages +are deferred, and later drawings leave them as they are until their input or parameters change. Effect › Refresh +Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget, and `effect.deferredStatus` lists +them with the reason (`budget`, `timeout`, `missing`, `failed`, `safeMode`). Each document has its own budget. +Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has no clock for the +budget, so there the first view runs no live effect and Refresh Deferred Effects runs them. + ### Heavy filters Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f8fc74c47..55f1498b0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1641,6 +1641,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. From e76524fd8b6c61ba8fc3dcc458f6f0d4e6289636 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:40:25 -0800 Subject: [PATCH 07/40] Graphs: labels keep paragraph settings and anti-aliasing when the graph is drawn again Paragraph settings given to all of a graph label's text with text.setFormat (indents, spacing, hyphenation, Justification options, composer, direction, kashida, kinsoku, mojikumi, burasagari and the rest) and the label's anti-aliasing method were lost when the graph drew its labels again, because the graph kept only a character style for each kind of label. Each kind now also keeps its paragraph settings and anti-aliasing method, saved with the graph in its label styles, and the graph still sets each label's alignment for its place. The legend spaces its entries by the kept legend settings: Legend Across Top measures each label with its indents and spacing, both legend layouts make room for extra leading or paragraph spacing, and a label with a negative indent or negative space before moves right or down so it stays clear of its swatch and of the entry above it. --- crates/doc/src/graph.rs | 24 ++- crates/engine/src/cmd/graph.rs | 4 +- crates/engine/src/cmd/graph/generate.rs | 16 +- crates/engine/src/cmd/graph/labels.rs | 62 +++++++- crates/engine/src/cmd/graph/series.rs | 71 +++++++-- crates/engine/src/cmd/graph/tests.rs | 199 ++++++++++++++++++++++++ crates/engine/src/cmd/panelcmds.rs | 23 ++- docs/mcp.md | 9 ++ docs/target-app-parity.md | 4 +- 9 files changed, 379 insertions(+), 33 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 72b066a06..f3b655d76 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -136,9 +136,11 @@ impl crate::Document { } } -/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the -/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or -/// across the top, or in pie wedges). `None` draws the default 9 pt black. +/// The character style and anti-aliasing method a graph's labels keep through regeneration once formatted (Format +/// the text in graphs): the value axis numbers, the category labels (category axis, radar spokes, pie names) and the +/// legend labels (beside or across the top, or in pie wedges). `None` draws the default 9 pt black. The `*_para` +/// fields are the paragraph attributes each kind keeps the same way (`None`: the defaults); the graph still sets each +/// label's alignment. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct LabelStyles { @@ -148,6 +150,22 @@ pub struct LabelStyles { pub categories: Option, #[serde(skip_serializing_if = "Option::is_none")] pub legend: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub values_para: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_para: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_para: Option, + /// The anti-aliasing method the value axis numbers keep once set (Character panel › Anti-aliasing Method). + /// `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_anti_alias: Option, + /// The anti-aliasing method the category labels keep once set. `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_anti_alias: Option, + /// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_anti_alias: Option, } impl LabelStyles { diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 8c838d421..f4b6c90c1 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -43,8 +43,8 @@ use generate::generate; #[cfg(test)] use series::default_series_color; pub(crate) use series::{ - capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, - series_object_opacity_differs, shown_paint, + capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, + capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index d1a5ddde0..2f16d5e42 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -2,7 +2,8 @@ use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, rada use super::columns::{Allowance, ColumnArt, column_art, column_designs}; use super::data::fmt_value; use super::labels::{ - Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of, + Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_extra_height, label_shift, label_size, label_width, polyline, + recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; use super::series::{series_marks, with_series_opacity}; @@ -901,6 +902,8 @@ impl<'a> Plot<'a> { fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc { let ls = self.ls; + let (dx, dy) = label_shift(&self.g.label_styles, label); + let (x, y) = (x + dx, y + dy); self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false) } @@ -909,10 +912,11 @@ impl<'a> Plot<'a> { fn legend_across_top(&mut self) -> Vec> { let (g, r, ls) = (self.g, self.r, self.ls); let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect(); - let mut rows: Vec> = vec![vec![]]; + let mut rows: Vec> = vec![vec![]]; let mut used = 0.0; for (s, label) in entries { let w = 12.0 + label_width(&g.label_styles, &label) + 12.0; + let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label); // The gap after an entry may run past the plot's edge. if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) { rows.push(vec![]); @@ -920,10 +924,10 @@ impl<'a> Plot<'a> { } used += w; if let Some(row) = rows.last_mut() { - row.push((s, label, w)); + row.push((s, label, w, h)); } } - let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64; + let height = |row: &[(usize, String, f64, f64)]| row.iter().map(|(.., h)| *h).fold(ls * 1.8, f64::max); // Above the plot and anything drawn over it (radar and stacked-pie labels). let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); let top = self.bars_reach.map_or(top, |b| top.min(b.y0)); @@ -931,7 +935,7 @@ impl<'a> Plot<'a> { let mut labels = vec![]; for row in &rows { let mut x = r.x0; - for (s, label, w) in row { + for (s, label, w, _) in row { self.legend_swatch(*s, (x, y)); labels.push(self.legend_label((x, y), label)); x += w; @@ -954,7 +958,7 @@ impl<'a> Plot<'a> { y += ls * 1.8; continue; } - y += ls * 1.8 * label.split('|').count() as f64; + y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label); self.legend_swatch(s, (x, row_y)); labels.push(self.legend_label((x, row_y), &label)); } diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index fdd640a95..7c0127280 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -10,7 +10,7 @@ use super::{ pub(super) struct Gen<'a> { pub(super) d: &'a mut Document, pub(super) out: Vec>, - /// The labels' kept character styles. + /// The labels' kept character styles and paragraph attributes. pub(super) styles: &'a vectorcraft_doc::LabelStyles, /// What the column designs may still add. pub(super) columns: Allowance, @@ -45,6 +45,30 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) -> st } +/// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the +/// alignment. +pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle { + let kept = match role { + Role::Value => &styles.values_para, + Role::Category => &styles.categories_para, + Role::Legend => &styles.legend_para, + }; + let mut para = kept.clone().unwrap_or_default(); + // A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label). + para.keep_spacing_in_range(); + para +} + +/// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp. +pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias { + let kept = match role { + Role::Value => styles.values_anti_alias, + Role::Category => styles.categories_anti_alias, + Role::Legend => styles.legend_anti_alias, + }; + kept.unwrap_or_default() +} + /// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it. pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 { label_style(styles, role).size @@ -63,6 +87,8 @@ impl Gen<'_> { pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { let style = label_style(self.styles, role); let mut t = TextObject::point(at, s, style); + t.para = label_para(self.styles, role); + t.anti_alias = label_anti_alias(self.styles, role); t.para.justify = justify; t.system_layout = self.d.setup.system_layout; refresh_bounds(&mut t); @@ -162,12 +188,40 @@ pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc { Arc::new(d.reid(&c)) } -/// How wide label `s` is set (its widest line), without adding it to a document. -pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { +/// The bounds of legend label `s` set from the origin with paragraph attributes `para`, without adding it to a document. +fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle) -> Option { let style = label_style(styles, Role::Legend); let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style); + t.para = para; + t.para.justify = Justify::Left; refresh_bounds(&mut t); - Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width()) + Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds() +} + +/// How far label `s` reaches right of where it starts (its widest line and any indent), after the legend moves it right +/// by [`label_shift`], without adding it to a document. +pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { + legend_label_bounds(styles, s, label_para(styles, Role::Legend)).map_or(0.0, |b| (b.x1 + label_shift(styles, s).0).max(0.0)) +} + +/// The bounds of legend label `s` set with its kept paragraph attributes and with the defaults, when it has kept ones. +fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str) -> Option<(Rect, Rect)> { + styles.legend_para.as_ref()?; + Some((legend_label_bounds(styles, s, label_para(styles, Role::Legend))?, legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default())?)) +} + +/// How much further left and further up legend label `s` reaches with its kept paragraph attributes than with the +/// defaults (a negative indent or negative spacing), so the legend moves it right and down by that much and it stays +/// clear of its swatch and of the entries before it. +pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str) -> (f64, f64) { + kept_and_default_bounds(styles, s).map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0))) +} + +/// How much further down legend label `s` reaches with its kept paragraph attributes than with the defaults (more +/// leading, or space before or after), after the legend moves it down by [`label_shift`], so the legend makes room +/// for it. +pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { + kept_and_default_bounds(styles, s).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0)) } /// A label with nothing to show (empty, or only `|` line breaks). diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 7d7d75a16..229a4d4b4 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,7 +1,7 @@ use vectorcraft_color::BlendMode; use super::axis::radar_axis; -use super::labels::{Role, filled_lines_on, label_style}; +use super::labels::{Role, filled_lines_on, label_para, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- @@ -406,23 +406,34 @@ fn graph_labels(doc: &Document) -> HashMap { out } -/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels -/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or -/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so -/// they undo together. Returns whether a kept style changed. -pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { - if ids.is_empty() { - return false; - } +/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as +/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed. +pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool { let labels = graph_labels(doc); - if labels.is_empty() { - return false; - } let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect(); kinds.sort_by_key(|(g, r)| (g.0, *r as u8)); kinds.dedup(); let mut changed = false; for (graph, role) in kinds { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let slot = match role { + Role::Value => &mut spec.label_styles.values_anti_alias, + Role::Category => &mut spec.label_styles.categories_anti_alias, + Role::Legend => &mut spec.label_styles.legend_anti_alias, + }; + changed |= *slot != Some(method); + *slot = Some(method); + } + changed +} + +/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels +/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or +/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so +/// they undo together. Returns whether a kept style changed. +pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { + let mut changed = false; + for (graph, role) in label_kinds(doc, ids) { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let mut style = label_style(&spec.label_styles, role); change(&mut style); @@ -437,6 +448,42 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: & changed } +/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels +/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character +/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes. +pub(crate) fn capture_label_paragraphs( + doc: &mut Document, + ids: &[NodeId], + change: &dyn Fn(&mut vectorcraft_doc::ParaStyle) -> Result<()>, +) -> Result { + let mut changed = false; + for (graph, role) in label_kinds(doc, ids) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let mut para = label_para(&spec.label_styles, role); + change(&mut para)?; + let slot = match role { + Role::Value => &mut spec.label_styles.values_para, + Role::Category => &mut spec.label_styles.categories_para, + Role::Legend => &mut spec.label_styles.legend_para, + }; + changed |= slot.as_ref() != Some(¶); + *slot = Some(para); + } + Ok(changed) +} + +/// The graphs and kinds of label among `ids`, each once. +fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> { + if ids.is_empty() { + return vec![]; + } + let labels = graph_labels(doc); + let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect(); + kinds.sort_by_key(|(g, r)| (g.0, *r as u8)); + kinds.dedup(); + kinds +} + /// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a /// selection made only of this graph's series (Group Selection). Empty: the whole graph. pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 6cb36431e..90c561284 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4055,6 +4055,45 @@ fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() { assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default()); } +/// An anti-aliasing method given to one category label is kept for every category label when the graph is drawn +/// again, through save and open, and undoes with the change. +#[test] +fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() { + use vectorcraft_doc::text::TypeAntiAlias; + let methods = |s: &Session, id: NodeId| -> Vec<(String, TypeAntiAlias)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for g in ["Axes", "Legend"] { + for c in group(&n, g).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + out.push((t.plain_text(), t.anti_alias)); + } + } + } + out + }; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("text.setFormat", &json!({"ids": [c.0], "antiAlias": "strong"})).unwrap(); + label_looks(&mut s, id); + let drawn = methods(&s, id); + let method = |t: &str| drawn.iter().find(|(x, _)| x == t).map(|(_, m)| *m).unwrap(); + assert_eq!((method("Q1"), method("Q3")), (TypeAntiAlias::Strong, TypeAntiAlias::Strong), "every category label: {drawn:?}"); + assert_eq!((method("0"), method("2024")), (TypeAntiAlias::Sharp, TypeAntiAlias::Sharp), "other labels: {drawn:?}"); + // One undo takes the data change back, the next the method and the kept method together. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None); + s.execute("edit.redo", &json!({})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong)); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); +} + /// A stroke given to all of a graph's text without a weight is kept at 1 pt, as `paint.setStroke` keeps it. #[test] fn a_stroke_given_to_the_whole_graphs_text_is_kept_at_1_pt() { @@ -4219,3 +4258,163 @@ fn csv_round_trip_keeps_blank_row_in_single_column_graph() { assert!(c.is_empty()); assert_eq!(r, g.cells()); } + +/// Each generated label's text, paragraph attributes and alignment. +fn label_paras(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_doc::ParaStyle)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for name in ["Axes", "Legend"] { + for c in group(&n, name).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + out.push((t.plain_text(), t.para.clone())); + } + } + } + out +} + +#[test] +fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() { + use vectorcraft_doc::{Composer, ParaDirection, text::Kashida}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let aligned: Vec<_> = label_paras(&s, id).into_iter().map(|(t, p)| (t, p.justify)).collect(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setFormat", &json!({"composer": "singleLine", "kashida": "long", "direction": "rightToLeft", "wordSpacing": [70, 90, 120]})) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let paras = label_paras(&s, id); + assert!(!paras.is_empty()); + for (text, p) in ¶s { + assert_eq!(p.composer, Composer::SingleLine, "{text}"); + assert_eq!(p.kashida, Kashida::Long, "{text}"); + assert_eq!(p.direction, Some(ParaDirection::RightToLeft), "{text}"); + assert_eq!(p.justification.word_spacing, [70.0, 90.0, 120.0], "{text}"); + } + // The graph still aligns each label to its place. + assert_eq!(paras.iter().map(|(t, p)| (t.clone(), p.justify)).collect::>(), aligned); + // One kind of label: only that kind keeps it, also through save and open. + let col = graph(&mut s, "column"); + let v = text_id(&s, col, "Axes", "4"); + s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let paras = label_paras(&s, col); + let composer = |t: &str| paras.iter().find(|(x, _)| x == t).unwrap().1.composer; + assert_eq!(composer("0"), Composer::SingleLine, "every value axis number"); + assert_eq!(composer("Q1"), Composer::default(), "category labels as they were"); + assert_eq!(composer("2024"), Composer::default(), "legend labels as they were"); + // Part of a label's text: nothing kept. + let q = text_id(&s, col, "Axes", "Q2"); + s.execute("select.set", &json!({"ids": [q.0]})).unwrap(); + s.execute("text.setFormat", &json!({"kashida": "short", "start": 0, "end": 1})).unwrap(); + s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + assert!(label_paras(&s, col).iter().all(|(_, p)| p.kashida == Kashida::default())); + // Spacing that would be out of order in the kept attributes is refused, even when it is in order on the label. + let v = text_id(&s, col, "Axes", "4"); + s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); + s.execute("text.setFormat", &json!({"wordSpacing": [60, 70, 80]})).unwrap(); + s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("text.setFormat", &json!({"wordSpacing": [80, 100, 133], "start": 0, "end": 1})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"wordSpacing": {"min": 90}})).is_err()); + let kept = spec(&s, col).label_styles.values_para.unwrap(); + assert_eq!(kept.justification.word_spacing, [60.0, 70.0, 80.0]); +} + +#[test] +fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let l = text_id(&s, id, "Legend", "2024"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": 40})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "2024")).unwrap(); + let label = label.geometric_bounds().unwrap(); + let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}"); +} + +#[test] +fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() { + for across_top in [true, false] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"legendAcrossTop": across_top})).unwrap(); + let l = text_id(&s, id, "Legend", "2025"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": -40})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = + group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, label) = (bounds("2024"), bounds("2025")); + let swatch = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(label.x0 >= swatch.x1, "the label starts after its swatch (across top: {across_top}): {swatch:?} {label:?}"); + if across_top { + assert!(swatch.x0 >= first.x1, "the swatch starts after the label before it: {first:?} {swatch:?}"); + } + } +} + +#[test] +fn a_legend_makes_room_for_the_kept_leading_of_its_labels() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",20|24,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let l = text_id(&s, id, "Legend", "20\n24"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, next) = (bounds("20\n24"), bounds("2025")); + assert!(next.y0 >= first.y1, "the next legend label starts below the first one: {first:?} {next:?}"); +} + +#[test] +fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,20|25\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let l = text_id(&s, id, "Legend", "20\n25"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"spaceBefore": -60})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, next) = (bounds("2024"), bounds("20\n25")); + assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}"); +} diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 04b1ce685..77bd4e4c1 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -786,6 +786,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { } }; s.edit("Character", |d, _| { + // The paragraph change, for the graph labels to keep. + let mut para_change = None; + let out_of_order_error = |name: &str| bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")); for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; @@ -794,7 +797,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy = (local(tcy_up_down)?, local(tcy_left_right)?); super::typecmd::style_chars(t, range, |st| chars(st, baseline_shift, h_scale, tcy)); let span = super::typecmd::para_span(range, t); - t.edit_paras(span, |para| { + let paras = |para: &mut vectorcraft_doc::ParaStyle, out_of_order: &mut Option<&'static str>| { if let Some(v) = num("leftIndent") { para.left_indent = v.clamp(-MAX_PARA, MAX_PARA); } @@ -882,17 +885,27 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = composer { para.composer = v; } - }); + }; + t.edit_paras(span, |para| paras(para, &mut out_of_order)); if let Some(name) = out_of_order { - return Err(bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum"))); + return Err(out_of_order_error(name)); } super::typecmd::refresh_bounds(t); + para_change = Some(paras); } // Graph labels keep a change made to all of their text through the graph being drawn again. if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + if let Some(paras) = para_change { + // The kept attributes are checked too: they may differ from the labels'. + super::graph::capture_label_paragraphs(d, &ids, &|para| { + let mut out_of_order = None; + paras(para, &mut out_of_order); + out_of_order.map_or(Ok(()), |name| Err(out_of_order_error(name))) + })?; + } } if let Some(v) = anti_alias { for id in &ids { @@ -900,6 +913,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { t.anti_alias = v; } } + // Graph labels keep the method through the graph being drawn again. + super::graph::capture_label_anti_alias(d, &ids, v); } Ok(()) })?; diff --git a/docs/mcp.md b/docs/mcp.md index 19f16205c..5ab211dcb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -595,6 +595,12 @@ not store a series' paint. An `appearance.*` command or `graphicStyle.apply` giv `graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store them. +`text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes +it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that +kind takes them when the graph is drawn again. The graph saves the paragraph attributes as `valuesPara`, +`categoriesPara` and `legendPara` in its label styles, and still sets each label's alignment for its place. The legend +leaves room for a legend label's stored indents, spacing and leading. + `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape those outlines. On paths, compound paths and images it does not change what is drawn. @@ -6758,6 +6764,9 @@ With Preferences › General › Anti-aliased Artwork off, the canvas draws all so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export draw the type unchanged. The native document such a file carries (an EPS VectorCraft wrote, or an SVG or PDF saved with Preserve Editing) keeps each frame's method, and opening the file restores it. +A graph label given a method keeps it for every label of its kind (value axis numbers, category labels or legend +labels) when the graph is drawn again; the graph saves it as `valuesAntiAlias`, `categoriesAntiAlias` or +`legendAntiAlias` in its label styles. Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f8fc74c47..0b7c77849 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -957,7 +957,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. @@ -1115,7 +1115,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. - The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. + The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. From cc2ec8f292547a03ec458c100cf3e4cab9decee3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 00:00:48 -0800 Subject: [PATCH 08/40] =?UTF-8?q?Scripts:=20a=20script=20chosen=20from=20F?= =?UTF-8?q?ile=20=E2=80=BA=20Scripts=20reads=20its=20#include=20files=20an?= =?UTF-8?q?d=20runs=20$.evalFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Scripts ran an installed script as source text, so the run had no file: an #include line ended it with "script file read refused", and $.evalFile was refused too. The menu now checks the script as before and runs it with script.run {path}, as docs/mcp.md describes, so its included files and the files $.evalFile runs are read beside it. --- crates/ui-egui/src/scripts_menu.rs | 7 ++++--- crates/ui-egui/src/tests/scripts_menu.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/scripts_menu.rs b/crates/ui-egui/src/scripts_menu.rs index e62a5d887..9c890d5c3 100644 --- a/crates/ui-egui/src/scripts_menu.rs +++ b/crates/ui-egui/src/scripts_menu.rs @@ -242,13 +242,14 @@ pub fn readable(p: &Value) -> bool { } /// `ui.runInstalledScript {path}`: run the listed script at `path`. A script that is no longer in -/// the folder is not run, and the folder is read again. +/// the folder is not run, and the folder is read again. The script runs from its file, so its +/// `#include` lines and `$.evalFile` read beside it. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let path = p.get("path").and_then(Value::as_str).ok_or("missing `path`")?; - let source = app.session.installed_script(path).map_err(|e| { + app.session.installed_script(path).map_err(|e| { let ctx = app.ui_ctx.clone(); refresh(app, ctx.as_ref()); e.to_string() })?; - app.run("script.run", json!({"source": source, "name": vectorcraft_engine::cmd::fileio::file_name(path)})) + app.run("script.run", json!({ "path": path })) } diff --git a/crates/ui-egui/src/tests/scripts_menu.rs b/crates/ui-egui/src/tests/scripts_menu.rs index 3dc66acca..751bd528a 100644 --- a/crates/ui-egui/src/tests/scripts_menu.rs +++ b/crates/ui-egui/src/tests/scripts_menu.rs @@ -142,6 +142,20 @@ fn a_script_runs_its_own_file_whatever_its_name_ends_with() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn a_listed_script_reads_its_included_files_and_runs_files_beside_it() { + let dir = folder("include"); + let mut app = app(&dir); + write(&dir, "lib/two.jsxinc", DRAW); + write(&dir, "lib/one.js", "vc.run('shape.rectangle', {x:40,y:0,width:10,height:10});"); + let main = write(&dir, "Main.jsx", "#include \"lib/two.jsxinc\"\n$.evalFile('lib/one.js');\n"); + read(&app); + menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": main })).unwrap().unwrap(); + finish(&mut app); + assert_eq!(objects(&app), 3, "{}", app.ui.status); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn script_and_folder_names_are_shown_as_they_are_in_every_language() { let dir = folder("language"); From c09e1d3ebedbce1e08ce2c723a13734f17914a89 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 00:33:28 -0800 Subject: [PATCH 09/40] Scripts: closing the quit question when the script it asked about has ended A window-close request while a script waits at its alert, confirm or prompt opens the quit question over that dialog, and Cancel shows the script's dialog again. When the run then ended another way (script.stop, or the run finishing), the quit question stayed open, and Cancel showed a dialog for a script that was no longer running. The running-script poll now closes the quit question once the run ends, and forgets the script's dialog under it once the run no longer waits at that dialog. --- crates/ui-egui/src/dialogs/script_run.rs | 16 +++++++++++++ crates/ui-egui/src/script_runs.rs | 2 ++ crates/ui-egui/src/tests/script_dialogs.rs | 26 ++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/crates/ui-egui/src/dialogs/script_run.rs b/crates/ui-egui/src/dialogs/script_run.rs index eb89bd174..8f5e3b109 100644 --- a/crates/ui-egui/src/dialogs/script_run.rs +++ b/crates/ui-egui/src/dialogs/script_run.rs @@ -182,6 +182,22 @@ pub(crate) fn ask_to_close(app: &mut VectorcraftApp) { app.ui.dialog = Some(Dialog::new(CLOSE_KIND, fields)); } +/// Closes the question quitting asks once the run it asks about has ended, and forgets the +/// script's dialog it was asked over once the run no longer waits at that dialog, so Cancel does +/// not show it again. +pub(crate) fn settle_close(app: &mut VectorcraftApp) { + if !app.ui.dialog.as_ref().is_some_and(|d| d.kind == CLOSE_KIND) { + return; + } + if !app.session.script_running() { + app.ui.dialog = None; + } else if app.session.script_dialog().is_none() + && let Some(d) = app.ui.dialog.as_mut() + { + d.fields.remove(super::PARENT); + } +} + /// Shows the dialog the running script waits at. pub(crate) fn open_notice(app: &mut VectorcraftApp, d: &ScriptDialog) { let fields = json!({ diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs index 65a9dc9eb..02c222974 100644 --- a/crates/ui-egui/src/script_runs.rs +++ b/crates/ui-egui/src/script_runs.rs @@ -15,6 +15,7 @@ pub fn poll(app: &mut VectorcraftApp) { if !app.session.script_running() { return; } + script_run::settle_close(app); if ask(app) { return; } @@ -31,6 +32,7 @@ pub fn poll(app: &mut VectorcraftApp) { if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::GROWTH_KIND) { app.ui.dialog = None; } + script_run::settle_close(app); match result { Ok(_) => app.status(format!("Ran {name}")), Err(e) => failed(app, e.to_string()), diff --git a/crates/ui-egui/src/tests/script_dialogs.rs b/crates/ui-egui/src/tests/script_dialogs.rs index da09c777b..b3750001a 100644 --- a/crates/ui-egui/src/tests/script_dialogs.rs +++ b/crates/ui-egui/src/tests/script_dialogs.rs @@ -220,6 +220,32 @@ fn close_while_notification_waits_abandons_or_resumes() { assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); } +#[test] +fn close_question_ends_with_a_run_stopped_another_way() { + let mut app = app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + dialogs::script_run::ask_to_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND); + assert_eq!(app.run("script.stop", json!({})).unwrap()["stopped"], true); + script_runs::poll(&mut app); + assert!(!app.session.script_running()); + assert!(app.ui.dialog.is_none(), "the question about a run that has ended closes"); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "Cancel does not show the stopped script's dialog again"); + assert_eq!(objects(&app), 0); + // Asked to stop, the run no longer waits at its dialog before its next slice ends it. + let mut app = self::app(); + start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));")); + assert!(until_dialog(&mut app)); + dialogs::script_run::ask_to_close(&mut app); + app.run("script.stop", json!({})).unwrap(); + dialogs::script_run::settle_close(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND, "the run has not ended yet"); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "Cancel does not show a dialog the run no longer waits at"); +} + #[test] fn notification_layout_with_long_text_and_small_window() { let mut app = app(); From 50bf4fc5a3d0d2967e28b0925d6259e870508ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Tue, 10 Nov 2026 16:07:36 +0000 Subject: [PATCH 10/40] G5: built-in Inventory Icons symbol library --- crates/engine/src/cmd/symbollib.rs | 63 +++++++++++++++++++ crates/engine/src/tests/symbollib.rs | 32 +++++++++- crates/ui-egui/src/i18n/bg.tsv | 6 ++ crates/ui-egui/src/i18n/bs.tsv | 6 ++ crates/ui-egui/src/i18n/ca.tsv | 6 ++ crates/ui-egui/src/i18n/cs.tsv | 6 ++ crates/ui-egui/src/i18n/da.tsv | 6 ++ crates/ui-egui/src/i18n/de.tsv | 6 ++ crates/ui-egui/src/i18n/el.tsv | 6 ++ crates/ui-egui/src/i18n/es.tsv | 6 ++ crates/ui-egui/src/i18n/fi.tsv | 6 ++ crates/ui-egui/src/i18n/fr.tsv | 6 ++ crates/ui-egui/src/i18n/hi.tsv | 6 ++ crates/ui-egui/src/i18n/hu.tsv | 6 ++ crates/ui-egui/src/i18n/id.tsv | 6 ++ crates/ui-egui/src/i18n/it.tsv | 6 ++ crates/ui-egui/src/i18n/ja.tsv | 6 ++ crates/ui-egui/src/i18n/ka.tsv | 6 ++ crates/ui-egui/src/i18n/ko.tsv | 6 ++ crates/ui-egui/src/i18n/nl.tsv | 6 ++ crates/ui-egui/src/i18n/pl.tsv | 6 ++ crates/ui-egui/src/i18n/pt-br.tsv | 6 ++ crates/ui-egui/src/i18n/ru.tsv | 6 ++ crates/ui-egui/src/i18n/sv.tsv | 6 ++ crates/ui-egui/src/i18n/tr.tsv | 6 ++ crates/ui-egui/src/i18n/uk.tsv | 6 ++ crates/ui-egui/src/i18n/vi.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 ++ .../src/panels/tests_symbol_libraries.rs | 2 +- docs/gaps.md | 13 ++-- docs/roadmap.md | 5 +- docs/target-app-parity.md | 13 ++-- 33 files changed, 274 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index fe3f1149d..c757d93ea 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -158,6 +158,7 @@ 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 }, + BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons }, ]; /// Built-in library `id`, made once. @@ -249,6 +250,68 @@ fn map_markers() -> SymbolLibrary { SymbolLibrary { name: "Map Markers".into(), doc } } +/// Inventory Icons: sword, shield, potion, key and gem for loot/RPG art (original, no 9-slice). +fn inventory_icons() -> SymbolLibrary { + let c = Point::ZERO; + let sword = [ + (0.0, -22.0), + (3.0, -10.0), + (3.0, 6.0), + (8.0, 8.0), + (8.0, 12.0), + (3.0, 12.0), + (3.0, 20.0), + (-3.0, 20.0), + (-3.0, 12.0), + (-8.0, 12.0), + (-8.0, 8.0), + (-3.0, 6.0), + (-3.0, -10.0), + ]; + let shield = [(0.0, -20.0), (16.0, -12.0), (14.0, 8.0), (0.0, 20.0), (-14.0, 8.0), (-16.0, -12.0)]; + let potion = + [(-4.0, -20.0), (4.0, -20.0), (4.0, -12.0), (10.0, -6.0), (12.0, 6.0), (8.0, 18.0), (-8.0, 18.0), (-12.0, 6.0), (-10.0, -6.0), (-4.0, -12.0)]; + let key = [ + (-4.0, -18.0), + (4.0, -18.0), + (6.0, -14.0), + (6.0, -8.0), + (4.0, -6.0), + (4.0, 16.0), + (8.0, 16.0), + (8.0, 20.0), + (-2.0, 20.0), + (-2.0, 12.0), + (0.0, 12.0), + (0.0, -6.0), + (-4.0, -8.0), + (-6.0, -14.0), + ]; + let sword: Vec = sword.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let shield: Vec = shield.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let potion: Vec = potion.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let key: Vec = key.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let icons = [ + ("Sword", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&sword, true)), "#95a5a6"), + ("Shield", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&shield, true)), "#3498db"), + ("Potion", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&potion, true)), "#9b59b6"), + ("Key", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&key, true)), "#f1c40f"), + ("Gem", shapes::polygon(c, 16.0, 6, 0.0), "#1abc9c"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in icons { + 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 = "Inventory Icons".into(); + SymbolLibrary { name: "Inventory Icons".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 410bd473d..febc22029 100644 --- a/crates/engine/src/tests/symbollib.rs +++ b/crates/engine/src/tests/symbollib.rs @@ -84,7 +84,7 @@ 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(), 3, "{libs:?}"); + assert_eq!(libs.len(), 4, "{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()), @@ -94,6 +94,10 @@ fn the_builtin_library_is_original_art_generated_in_code() { (libs[2]["id"].as_str(), libs[2]["name"].as_str(), libs[2]["category"].as_str()), (Some("map-markers"), Some("Map Markers"), Some("builtIn")) ); + assert_eq!( + (libs[3]["id"].as_str(), libs[3]["name"].as_str(), libs[3]["category"].as_str()), + (Some("inventory-icons"), Some("Inventory Icons"), 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"]); @@ -129,6 +133,32 @@ fn ui_frames_symbols_carry_default_nine_slice_when_added() { )); } +#[test] +fn inventory_icons_symbols_add_without_nine_slice() { + let mut s = session(); + let lib = run(&mut s, "symbol.library", json!({"library": "inventory-icons"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Sword", "Shield", "Potion", "Key", "Gem"]); + let mut placed = Vec::new(); + for (i, name) in names.iter().enumerate() { + let r = run( + &mut s, + "symbol.addFromLibrary", + json!({"library": "inventory-icons", "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 == "Sword" + )); +} + #[test] fn map_markers_symbols_add_without_nine_slice() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 602bc20a7..2d4949879 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5891,6 +5891,12 @@ axis Y Axis Y ос Flag Флаг Crosshair Мерник Checkpoint Контролна точка + Inventory Icons Икони на инвентара + Sword Меч + Shield Щит + Potion Отвара + Key Ключ + Gem Скъпоценен камък 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 9dda9c2c9..912eec6a6 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5935,6 +5935,12 @@ axis Y Axis Y osa Flag Zastava Crosshair Nišan Checkpoint Kontrolna tačka + Inventory Icons Ikone inventara + Sword Mač + Shield Štit + Potion Napitak + Key Ključ + Gem Dragulj 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 85ceed05f..f12bb4a6a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -455,6 +455,12 @@ antiAlias Sharp Nítid Flag Bandera Crosshair Punt de mira Checkpoint Punt de control + Inventory Icons Icones d’inventari + Sword Espasa + Shield Escut + Potion Poció + Key Clau + Gem Gemm 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 4fbbd523f..6d7965acd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6119,6 +6119,12 @@ axis Y Axis osa Y Flag Vlajka Crosshair Zaměřovač Checkpoint Kontrolní bod + Inventory Icons Ikony inventáře + Sword Meč + Shield Štít + Potion Lektvar + Key Klíč + Gem Drahokam 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 15ce8a99f..53036e9dd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5906,6 +5906,12 @@ axis Y Axis Y-akse Flag Flag Crosshair Sigtekorn Checkpoint Kontrolpunkt + Inventory Icons Inventarikoner + Sword Sværd + Shield Skjold + Potion Eliksir + Key Nøgle + Gem Ædelsten 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 b60b62216..e46a1b6ea 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5702,6 +5702,12 @@ axis Y Axis Y-Achse Flag Flagge Crosshair Fadenkreuz Checkpoint Kontrollpunkt + Inventory Icons Inventar-Symbole + Sword Schwert + Shield Schild + Potion Trank + Key Schlüssel + Gem Edelstein 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 c2145c61c..98015264e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6070,6 +6070,12 @@ axis Y Axis Άξονας Y Flag Σημαία Crosshair Σταυρόνημα Checkpoint Σημείο ελέγχου + Inventory Icons Εικονίδια αποθέματος + Sword Ξίφος + Shield Ασπίδα + Potion Φίλτρο + Key Κλειδί + Gem Πετράδι 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 f91e8ac2f..d5d844b23 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6061,6 +6061,12 @@ axis Y Axis Eje Y Flag Bandera Crosshair Punto de mira Checkpoint Punto de control + Inventory Icons Iconos de inventario + Sword Espada + Shield Escudo + Potion Poción + Key Llave + Gem Gema 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 a8b2062d7..85b49e77e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6149,6 +6149,12 @@ axis Y Axis Y-akseli Flag Lippu Crosshair Tähtäin Checkpoint Tarkastuskohta + Inventory Icons Inventaarioikonit + Sword Miekka + Shield Kilpi + Potion Taikajuoma + Key Avain + Gem Jalokivi 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 37fb556c6..43eb6d757 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6114,6 +6114,12 @@ axis Y Axis Axe Y Flag Drapeau Crosshair Réticule Checkpoint Point de contrôle + Inventory Icons Icônes d’inventaire + Sword Épée + Shield Bouclier + Potion Potion + Key Clé + Gem Gemme 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 1b8eb92c6..796939065 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -526,6 +526,12 @@ antiAlias Sharp शार्प Flag झंडा Crosshair क्रॉसहेयर Checkpoint चेकपॉइंट + Inventory Icons इन्वेंटरी आइकन + Sword तलवार + Shield ढाल + Potion औषधि + Key कुंजी + Gem रत्न Batch बैच Batch Trace Folder फ़ोल्डर बैच ट्रेस करें Batch… बैच… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7884c05f6..ec7d13a8c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6102,6 +6102,12 @@ axis Y Axis Y tengely Flag Zászló Crosshair Célkereszt Checkpoint Ellenőrzőpont + Inventory Icons Leltári ikonok + Sword Kard + Shield Pajzs + Potion Bájital + Key Kulcs + Gem Drágakő 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 4de51c549..45d30eb0f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5906,6 +5906,12 @@ axis Y Axis Sumbu Y Flag Bendera Crosshair Bidik silang Checkpoint Pos pemeriksaan + Inventory Icons Ikon Inventaris + Sword Pedang + Shield Perisai + Potion Ramuan + Key Kunci + Gem Permata 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 dc8dc1faf..419cf0582 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5736,6 +5736,12 @@ axis Y Axis Asse Y Flag Bandiera Crosshair Mirino Checkpoint Punto di controllo + Inventory Icons Icone inventario + Sword Spada + Shield Scudo + Potion Pozione + Key Chiave + Gem Gemma 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 8935be529..effd2a900 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5801,6 +5801,12 @@ axis Y Axis Y 軸 Flag 旗 Crosshair クロスヘア Checkpoint チェックポイント + Inventory Icons インベントリアイコン + Sword 剣 + Shield 盾 + Potion ポーション + Key 鍵 + Gem 宝石 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 64f744b88..e1b2fceef 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6042,6 +6042,12 @@ axis Y Axis Y ღერძი Flag დროშა Crosshair სამიზნე Checkpoint საკონტროლო წერტილი + Inventory Icons ინვენტარის ხატულები + Sword მახვილი + Shield ფარი + Potion წამალი + Key გასაღები + Gem ძვირფასი ქვა 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 88adb5a86..2fa677459 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5697,6 +5697,12 @@ axis Y Axis Y축 Flag 깃발 Crosshair 십자선 Checkpoint 체크포인트 + Inventory Icons 인벤토리 아이콘 + Sword 검 + Shield 방패 + Potion 포션 + Key 열쇠 + Gem 보석 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 ffced05d3..10c923cd6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5957,6 +5957,12 @@ axis Y Axis Y-as Flag Vlag Crosshair Vizier Checkpoint Controlepunt + Inventory Icons Inventarisiconen + Sword Zwaard + Shield Schild + Potion Drank + Key Sleutel + Gem Edelsteen 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 d73f5ca7e..a4f8580ff 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5973,6 +5973,12 @@ axis Y Axis Oś Y Flag Flaga Crosshair Celownik Checkpoint Punkt kontrolny + Inventory Icons Ikony ekwipunku + Sword Miecz + Shield Tarcza + Potion Mikstura + Key Klucz + Gem Klejnot 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 1cbf595ae..2496b775e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6034,6 +6034,12 @@ axis Y Axis Eixo Y Flag Bandeira Crosshair Mira Checkpoint Ponto de verificação + Inventory Icons Ícones de inventário + Sword Espada + Shield Escudo + Potion Poção + Key Chave + Gem Gema 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 53f1707a7..5f34c30ec 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5730,6 +5730,12 @@ axis Y Axis Ось Y Flag Флаг Crosshair Прицел Checkpoint Контрольная точка + Inventory Icons Иконки инвентаря + Sword Меч + Shield Щит + Potion Зелье + Key Ключ + Gem Самоцвет 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 3a6252295..aab245107 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6142,6 +6142,12 @@ axis Y Axis Y-axel Flag Flagga Crosshair Hårkors Checkpoint Kontrollpunkt + Inventory Icons Inventarieikoner + Sword Svärd + Shield Sköld + Potion Dryck + Key Nyckel + Gem Ädelsten 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 9e5300b23..12e5a14e3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6075,6 +6075,12 @@ axis Y Axis Y ekseni Flag Bayrak Crosshair Nişangah Checkpoint Kontrol noktası + Inventory Icons Envanter Simgeleri + Sword Kılıç + Shield Kalkan + Potion İksir + Key Anahtar + Gem Mücevher 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 053be196b..a7854dc66 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5716,6 +5716,12 @@ axis Y Axis Вісь Y Flag Прапор Crosshair Приціл Checkpoint Контрольна точка + Inventory Icons Піктограми інвентарю + Sword Меч + Shield Щит + Potion Зілля + Key Ключ + Gem Самоцвіт 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 7e2989339..ce683ff03 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5717,6 +5717,12 @@ axis Y Axis Trục Y Flag Cờ Crosshair Tâm ngắm Checkpoint Điểm kiểm tra + Inventory Icons Biểu tượng túi đồ + Sword Kiếm + Shield Khiên + Potion Thuốc + Key Chìa khóa + Gem Ngọc 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 14be622f9..91d0e4e74 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5957,6 +5957,12 @@ axis Y Axis Y 轴 Flag 旗帜 Crosshair 准星 Checkpoint 检查点 + Inventory Icons 物品栏图标 + Sword 剑 + Shield 盾 + Potion 药水 + Key 钥匙 + Gem 宝石 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 f111796d4..399377101 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5754,6 +5754,12 @@ axis Y Axis Y 軸 Flag 旗幟 Crosshair 準星 Checkpoint 檢查點 + Inventory Icons 物品欄圖示 + Sword 劍 + Shield 盾 + Potion 藥水 + Key 鑰匙 + Gem 寶石 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 7dee49df2..a6c1cb4cb 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"), json!("map-markers")]); + assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons")]); 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 8b6a32a29..f0619ff21 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 (G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously 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 @@ -622,9 +622,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 (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 + Libraries Menu open each library in a library panel of its own (four original libraries generated in code, + Basic Shapes, UI Frames with default 9-slice guides on Panel, Button and Bar, Map Markers with Pin, + Flag, Crosshair and Checkpoint, and Inventory Icons with Sword, Shield, Potion, Key and Gem; 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 @@ -652,10 +652,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the seven shipped, and further symbol libraries beyond Basic - Shapes, UI Frames and Map Markers; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; + Shapes, UI Frames, Map Markers and Inventory Icons; 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:** 8–18 h. +- **Estimate:** 7–17 h. ## G3 3D and Materials @@ -854,6 +854,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. | | 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: in Feature submission evidence, a stray table row and two "PR #NNNN revision" notes move into Feature revisions, the three "Feature evidence last updated" lines become one, the duplicate Flyout update note goes (its row stays), and eight reconciliation rows become two. Scores and estimates unchanged. | | 2026-11-10 | minor | G5: built-in Game HUD graphic style library (Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine, Boss Alert); Missing trimmed; estimate 10–20 → 9–19 h. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6613ebb22..02e0a61fc 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 (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -148,7 +148,7 @@ 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, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); 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; 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 (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 | | 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, 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 | +| 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, four generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, five 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; 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; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | 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, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | @@ -189,6 +189,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M10/G5: Inventory Icons built-in symbol library (Sword, Shield, Potion, Key, Gem); estimates unchanged | | 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | M10/G5: Game HUD built-in graphic style library; estimates unchanged | | 2026-11-10 | minor | M10/G5: Map Markers built-in symbol library (Pin, Flag, Crosshair, Checkpoint); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a8bc235f2..cdcda0f6e 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-11 · **Change:** minor (`.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -222,7 +222,7 @@ 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, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), 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, 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 | +| 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, Map Markers and Inventory Icons 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, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), 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, 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) | @@ -231,7 +231,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 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, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), 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, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), 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, 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 | +| 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, Map Markers and Inventory Icons 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) | @@ -495,7 +495,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, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | 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, 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 | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers and Inventory Icons 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 | @@ -538,7 +538,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 | ~38% | 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), graphic style libraries seven (including Game HUD) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~40% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have four (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons), graphic style libraries seven (including Game HUD) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) 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 | @@ -796,7 +796,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, 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`. + - **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), Map Markers (Pin, Flag, Crosshair and Checkpoint, no default 9-slice) and Inventory Icons (Sword, Shield, Potion, Key and Gem, 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 ``, 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`. @@ -1963,6 +1963,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Type advanced: the Edit Custom Dictionary dialog, #4936; evidence added; the Check Spelling dialog remains; scores and hours unchanged. | | 2026-11-10 | minor | File formats: in a `.ai` or EPS opened from its editing data, an envelope of type opens live where it draws as its drawn art and the layers with it draw as the page (the envelope weighed as the art it draws); elsewhere, and where a text or visibility variable binds its type, it keeps its drawn art, with a note (#3179); evidence updated; scores and hours unchanged. | | 2026-11-10 | minor | Bundled content: built-in Game HUD graphic style library; style libraries evidence updated; ~34% → ~36%; more built-in graphic-style libraries remain. | From 1b47f172c2098b8f16bde5dc98c04cc26c51e28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Tue, 10 Nov 2026 16:16:49 +0000 Subject: [PATCH 11/40] G5: list Inventory Icons in the UI parity checklist --- docs/ui-parity.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 38297994f..a0182867c 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-11 · **Change:** minor (files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -407,7 +407,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | | Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 | -| Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) | +| Symbol libraries: Window › Symbol Libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; four built-in libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons; original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select 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; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | | Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | | Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | @@ -507,6 +507,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). | | 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). 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 | 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. | From f23f7ef3211a2fba61950a85bb5f3605bce12b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Tue, 10 Nov 2026 19:48:58 +0000 Subject: [PATCH 12/40] G5: list Inventory Icons in the MCP symbol libraries docs --- docs/mcp.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index d8798c6df..9c28fbe5b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2991,7 +2991,8 @@ with the same extension and bytes as one there, loads as that User Defined libra ## Symbol libraries -Symbol libraries are read-only sets of symbols: one generated in code (Basic Shapes), the `.vectorcraft` files in +Symbol libraries are read-only sets of symbols: four generated in code (Basic Shapes, UI Frames, Map Markers, +Inventory Icons), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the symbols of any document loaded with `symbol.loadLibrary`. `symbol.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `symbol.library {library}` returns one's symbols (`name`, `size`, `registration`, `exportType`; `library` is an From da765088a0bbbb849d1e82e1b2cb952902d0d37e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 00:35:52 -0800 Subject: [PATCH 13/40] Graphs: the legend measures its labels with the document's System Layout The legend measured each label with System Layout off, while the graph draws the labels with the document's setting. With System Layout on, a long legend label could run over the next swatch in Legend Across Top. The legend now measures labels with the same setting. --- crates/engine/src/cmd/graph/generate.rs | 10 ++++----- crates/engine/src/cmd/graph/labels.rs | 27 ++++++++++++++++--------- crates/engine/src/cmd/graph/tests.rs | 27 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 2f16d5e42..a789304b2 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -902,7 +902,7 @@ impl<'a> Plot<'a> { fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc { let ls = self.ls; - let (dx, dy) = label_shift(&self.g.label_styles, label); + let (dx, dy) = label_shift(&self.g.label_styles, label, self.b.d.setup.system_layout); let (x, y) = (x + dx, y + dy); self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false) } @@ -910,13 +910,13 @@ impl<'a> Plot<'a> { /// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row /// just above it. fn legend_across_top(&mut self) -> Vec> { - let (g, r, ls) = (self.g, self.r, self.ls); + let (g, r, ls, system_layout) = (self.g, self.r, self.ls, self.b.d.setup.system_layout); let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect(); let mut rows: Vec> = vec![vec![]]; let mut used = 0.0; for (s, label) in entries { - let w = 12.0 + label_width(&g.label_styles, &label) + 12.0; - let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label); + let w = 12.0 + label_width(&g.label_styles, &label, system_layout) + 12.0; + let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label, system_layout); // The gap after an entry may run past the plot's edge. if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) { rows.push(vec![]); @@ -958,7 +958,7 @@ impl<'a> Plot<'a> { y += ls * 1.8; continue; } - y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label); + y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label, self.b.d.setup.system_layout); self.legend_swatch(s, (x, row_y)); labels.push(self.legend_label((x, row_y), &label)); } diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 7c0127280..faf301899 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -188,40 +188,47 @@ pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc { Arc::new(d.reid(&c)) } -/// The bounds of legend label `s` set from the origin with paragraph attributes `para`, without adding it to a document. -fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle) -> Option { +/// The bounds of legend label `s` set from the origin with paragraph attributes `para` and the document's System +/// Layout setting `system_layout`, as [`Gen::text`] sets it, without adding it to a document. +fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle, system_layout: bool) -> Option { let style = label_style(styles, Role::Legend); let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style); t.para = para; t.para.justify = Justify::Left; + t.system_layout = system_layout; refresh_bounds(&mut t); Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds() } /// How far label `s` reaches right of where it starts (its widest line and any indent), after the legend moves it right /// by [`label_shift`], without adding it to a document. -pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { - legend_label_bounds(styles, s, label_para(styles, Role::Legend)).map_or(0.0, |b| (b.x1 + label_shift(styles, s).0).max(0.0)) +pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 { + legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout) + .map_or(0.0, |b| (b.x1 + label_shift(styles, s, system_layout).0).max(0.0)) } /// The bounds of legend label `s` set with its kept paragraph attributes and with the defaults, when it has kept ones. -fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str) -> Option<(Rect, Rect)> { +fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> Option<(Rect, Rect)> { styles.legend_para.as_ref()?; - Some((legend_label_bounds(styles, s, label_para(styles, Role::Legend))?, legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default())?)) + Some(( + legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)?, + legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default(), system_layout)?, + )) } /// How much further left and further up legend label `s` reaches with its kept paragraph attributes than with the /// defaults (a negative indent or negative spacing), so the legend moves it right and down by that much and it stays /// clear of its swatch and of the entries before it. -pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str) -> (f64, f64) { - kept_and_default_bounds(styles, s).map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0))) +pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> (f64, f64) { + kept_and_default_bounds(styles, s, system_layout) + .map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0))) } /// How much further down legend label `s` reaches with its kept paragraph attributes than with the defaults (more /// leading, or space before or after), after the legend moves it down by [`label_shift`], so the legend makes room /// for it. -pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { - kept_and_default_bounds(styles, s).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0)) +pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 { + kept_and_default_bounds(styles, s, system_layout).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0)) } /// A label with nothing to show (empty, or only `|` line breaks). diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 90c561284..c5316e765 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4344,6 +4344,33 @@ fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() { assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}"); } +#[test] +fn a_legend_across_top_measures_its_labels_with_system_layout() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + s.execute("type.systemLayout", &json!({"on": true})).unwrap(); + // Each `i` at 2 pt advances a whole point with System Layout, far more than its own width. + let name = "i".repeat(60); + let csv = format!(",{name},2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"); + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let l = text_id(&s, id, "Legend", &name); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 2})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": 1})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == name)).unwrap(); + let label = label.geometric_bounds().unwrap(); + let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(next.x0 >= label.x1, "the next swatch starts after the label set with System Layout: {label:?} {next:?}"); +} + #[test] fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() { for across_top in [true, false] { From ba5eab7349e02277096b0b5e3fc70d3485e260a1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 00:36:07 -0800 Subject: [PATCH 14/40] Docs: Edit Symbol keeps dynamic symbol instances linked The Dynamic symbols entry still said Edit Symbol expands the symbol's own art. Since #5064, Edit Symbol opens symbol editing mode: every instance stays linked and a dynamic symbol's instances keep their changes for the objects the edited art still has. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a8bc235f2..2103ed624 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1896,7 +1896,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol keeps the instances linked and keeps each change whose object the edited art still has; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) From c941a1da5dc3735a99ab633e8b39a8abc604bc69 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 00:36:08 -0800 Subject: [PATCH 15/40] Contextual Task Bar: screen readers name the text buttons The bar's text buttons (Edit Path, Duplicate, Type on a Path Options and the others) were painted labels on a click area with no accessible name, so a screen reader announced them without one. Each button now reports its label as a button, as the Home screen's file rows do. --- crates/ui-egui/src/canvas/task_bar.rs | 1 + crates/ui-egui/src/canvas/tests.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index b5f8dac66..0ccba73de 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -233,6 +233,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { for (label, icon, cmd, params) in &items { let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index a9ef2c228..172475daa 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -9198,6 +9198,20 @@ fn the_task_bar_offers_the_artboard_tools_actions() { assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]); } +/// Screen readers name the Contextual Task Bar's text buttons by their labels. +#[test] +fn screen_readers_name_the_task_bars_text_buttons() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap(); + app.select_tool("selection"); + let tree = tree(|ui| show(&mut app, ui)); + for label in ["Edit Path", "Offset Path", "Simplify", "Duplicate"] { + assert_eq!(named(&tree, label).role(), egui::accesskit::Role::Button, "{label}"); + } +} + /// A selected group's Contextual Task Bar offers Ungroup and Isolate. While an opacity mask is /// edited, when Enter Isolation Mode is off, a group in the mask art gets Ungroup without Isolate. #[test] From bc63dc7285971b2ee7bcae55bd73d0f473131910 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 00:36:08 -0800 Subject: [PATCH 16/40] =?UTF-8?q?Scripts:=20the=20File=20=E2=80=BA=20Scrip?= =?UTF-8?q?ts=20command=20help=20says=20it=20runs=20the=20script=20by=20pa?= =?UTF-8?q?th?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/menus/ui_commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f1513f8df..06dcf85bc 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1536,7 +1536,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::scripts_menu::COMMAND, "Run Script", "", - "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with the file's text, under the file's name), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", ), ( "ui.checkScript", From 68042f0924174ab543f4bf4729a2ba29477bbd63 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 00:57:15 -0800 Subject: [PATCH 17/40] Docs: the Dynamic symbols evidence lists the Edit Symbol tests --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2103ed624..8d2d365fb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1896,7 +1896,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol keeps the instances linked and keeps each change whose object the edited art still has; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol keeps the instances linked and keeps each change whose object the edited art still has; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests, `crates/engine/src/tests/symboledit.rs`. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) From afdaa47cd51d65dcaec0044cc100201cfd216092 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:54:52 -0800 Subject: [PATCH 18/40] SVG import: a clip path made of text clips by the text A in a was dropped with a warning, so a clip path made only of text had no content and the art it clipped was hidden. The clipPath content model in CSS Masking Module Level 1 lists text and use (https://www.w3.org/TR/css-masking-1/#ClipPathElement). The importer now clips by the text's glyph outlines, together with any shapes in the same clip path, and keeps clipPathUnits, the transforms, clip-rule, visibility, the conditional processing attributes and a clip path on the text. A in a clip path that shows a text clips the same way, placed by the ; a clip path on the applies in the 's user space, and a reference goes to the first element with the id. When the clip path holds only one visible text, with no clip path of its own and no even-odd clip rule, the clipping path of the clip group is the text object with no fill or stroke, the same structure Make Clipping Mask produces for type. A text or a with an id in a clip path can still be shown by a outside it. Each text's outlines are laid out once, however many elements use the clip path. A transform set by a style rule or a style attribute on a text now places it, as it does a shape. Before, only the transform attribute was read, inside and outside clip paths. --- crates/engine/tests/import_fuzz.rs | 20 ++ crates/svg/src/import/convert.rs | 35 ++- crates/svg/src/import/importer.rs | 54 +++- crates/svg/src/import/text.rs | 251 +++++++++++++++-- crates/svg/src/lib.rs | 7 +- crates/svg/tests/it/clip_text.rs | 429 +++++++++++++++++++++++++++++ crates/svg/tests/it/import_text.rs | 13 + crates/svg/tests/it/main.rs | 1 + 8 files changed, 771 insertions(+), 39 deletions(-) create mode 100644 crates/svg/tests/it/clip_text.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 424f30cdd..f729ed394 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -663,6 +663,26 @@ proptest! { survive(&svg, || vectorcraft_svg::import(&svg).ok())?; } + /// Clip paths made of text, of `` elements showing text, and of both with shapes, each with + /// hostile attributes, clipping art that may clip by them in turn. + #[test] + fn svg_text_clip_paths_never_panic( + text in prop::collection::vec(arb_svg_attr(), 0..5), + used in prop::collection::vec(arb_svg_attr(), 0..5), + clip in prop::collection::vec(arb_svg_attr(), 0..3), + body in prop::collection::vec(arb_svg_element(1), 0..3), + ) { + let svg = format!( + "abcClipit{}", + text.concat(), + clip.concat(), + text.concat(), + used.concat(), + body.concat() + ); + survive(&svg, || vectorcraft_svg::import(&svg).ok())?; + } + #[test] fn svg_mutated_export_never_panics( cut in 0usize..20_000, diff --git a/crates/svg/src/import/convert.rs b/crates/svg/src/import/convert.rs index d8cf3bcd5..8bdadb031 100644 --- a/crates/svg/src/import/convert.rs +++ b/crates/svg/src/import/convert.rs @@ -192,49 +192,58 @@ const MAX_CLIP_DEPTH: usize = 16; /// multiply the work at every level: without this, a few lines of SVG would never finish. const MAX_CLIP_READS: usize = 1024; -/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document. -pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> { +/// The shapes a text placeholder path in a clip path stands for (the text's glyph outlines), given +/// the path and the transform `m` that maps its user space to the document; `None` for any other +/// path. +pub(super) type ClipText<'a> = &'a dyn Fn(&usvg::Path, Affine) -> Option>; + +/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document, with the +/// glyph outlines `text` gives for the text in it. +pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine, text: ClipText) -> Vec<(BezPath, FillRule)> { let mut reads = MAX_CLIP_READS; - clip_shapes_depth(cp, ts, 0, &mut reads) + clip_shapes_depth(cp, ts, 0, &mut reads, text) } /// Recursively collect shapes for `cp` in document space up to `MAX_CLIP_DEPTH`, and while `reads` /// are left. -fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize) -> Vec<(BezPath, FillRule)> { +fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize, text: ClipText) -> Vec<(BezPath, FillRule)> { if depth > MAX_CLIP_DEPTH || *reads == 0 { return Vec::new(); } *reads -= 1; - fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, out: &mut Vec<(BezPath, FillRule)>) { + fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, text: ClipText, out: &mut Vec<(BezPath, FillRule)>) { for c in g.children() { match c { usvg::Node::Group(g) => { let m_child = m * aff(g.transform()); if let Some(child_cp) = g.clip_path() { let mut child_shapes = Vec::new(); - collect(g, m_child, depth + 1, reads, &mut child_shapes); + collect(g, m_child, depth + 1, reads, text, &mut child_shapes); if !child_shapes.is_empty() { - let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads); + let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads, text); intersect_shapes(&child_shapes, &clip_paths, out); } } else { - collect(g, m_child, depth, reads, out); + collect(g, m_child, depth, reads, text, out); } } - usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + usvg::Node::Path(p) => match text(p, m) { + Some(glyphs) => out.extend(glyphs), + None => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + }, _ => {} } } } let mut shapes = Vec::new(); - collect(cp.root(), ts * aff(cp.transform()), depth, reads, &mut shapes); + collect(cp.root(), ts * aff(cp.transform()), depth, reads, text, &mut shapes); if !shapes.is_empty() && depth > 0 && depth < MAX_CLIP_DEPTH && *reads > 0 && let Some(parent_cp) = cp.clip_path() { - let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads); + let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads, text); let mut intersected = Vec::new(); intersect_shapes(&shapes, &parent_shapes, &mut intersected); shapes = intersected; @@ -267,7 +276,7 @@ fn intersect_shapes(shapes: &[(BezPath, FillRule)], clips: &[(BezPath, FillRule) /// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`, /// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.) -pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool { +pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine, text: ClipText) -> bool { let Some(b) = art.visual_bounds() else { return false }; if cp.clip_path().is_some() { return false; @@ -275,7 +284,7 @@ pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> // Art touching the clip's edge isn't cut by it. let e = 1e-6 * (b.width() + b.height()).max(1e-9); let b = b.inflate(-e, -e); - let shapes = clip_shapes(cp, ts); + let shapes = clip_shapes(cp, ts, text); let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c))) } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 6324e5380..56efe7b3a 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -194,7 +194,7 @@ impl Importer { _ => self.named("", NodeKind::Group { children: kids, clip: false })?, }; // usvg clips a `` of a symbol to its viewport: that only counts when it cuts the art. - let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf)); + let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf, &|p, m| self.clip_text(p, m))); let name = match self.symbols.get(&sym) { Some(made) if whole && made.art == without_ids(&art) && made.reuse.allows(xf) => made.name.clone(), Some(_) => return None, @@ -519,7 +519,10 @@ impl Importer { } fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { - let mut shapes = clip_shapes(cp, ts); + if let Some(n) = self.clip_type(cp, ts) { + return Some(n); + } + let mut shapes = clip_shapes(cp, ts, &|p, m| self.clip_text(p, m)); let id = cp.id().to_string(); if shapes.len() == 1 { let (bp, r) = shapes.pop()?; @@ -540,6 +543,53 @@ impl Importer { self.named(&id, NodeKind::Group { children, clip: false }) } + /// The glyph outlines, in the document, of the text a clip placeholder path `p` stands for (see + /// [`text`]), whose user space `m` maps to the document; nothing when the text is hidden. `None` + /// when `p` isn't a placeholder: in a clip path only its id tells (usvg paints clip path content + /// black). + fn clip_text(&self, p: &usvg::Path, m: Affine) -> Option> { + if p.id().is_empty() { + return None; + } + let i = self.slots.find(p)?; + let t = self.slots.texts.get(i)?; + if !p.is_visible() { + return Some(vec![]); + } + let mut bp = BezPath::clone(&*self.slots.outlines(i)?); + bp.apply_affine(m * self.slots.inner(p) * t.obj.xf); + let r = p.fill().map(|f| rule(f.rule())).unwrap_or_default(); + Some(if bp.elements().is_empty() { vec![] } else { vec![(bp, r)] }) + } + + /// Clip path `cp` (on an element whose user space `ts` maps to the document) as live type, when + /// all it holds is one visible text that clips by the non-zero rule and has no clip path of its + /// own: the text object with no fill or stroke, the same structure Make Clipping Mask produces + /// for type. + fn clip_type(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { + let mut m = ts * aff(cp.transform()); + let mut kids = cp.root().children(); + while let [usvg::Node::Group(g)] = kids + && g.clip_path().is_none() + { + m *= aff(g.transform()); + kids = g.children(); + } + let [usvg::Node::Path(p)] = kids else { return None }; + if p.id().is_empty() || !p.is_visible() || p.fill().is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { + return None; + } + let mut obj = self.slots.texts.get(self.slots.find(p)?)?.obj.clone(); + obj.xf = m * self.slots.inner(p) * obj.xf; + let grow = text::size_scale(&obj, obj.xf); + for run in &mut obj.runs { + run.style.fill = Paint::None; + run.style.stroke = Paint::None; + } + text::fold_scale(&mut obj, grow); + self.named(cp.id(), NodeKind::Text(Box::new(obj))) + } + /// One clip path child: a path, or a compound path of its subpaths when it has several. fn clip_shape(&mut self, bp: &BezPath, r: FillRule, clipping: bool) -> Option { let subs = PathData::from_bezpath(bp).subpaths; diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 2502bd2bd..904b76cd1 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -10,6 +10,13 @@ //! gradients and patterns in the text's user space; the importer swaps each placeholder for the //! [`PendingText`] it stands for. //! +//! usvg accepts only shapes, `` and `` as the children of a ``, so a `` +//! there (and a `` there that shows one) becomes a placeholder path instead, which the importer +//! finds by its id (`{prefix}{i}-…`). The importer clips by the text's glyph outlines, or uses the +//! text itself as live type. A `` in a clip path that has an id also gets the usual +//! placeholder group after its path, and a `` there with an id keeps a copy in a `` +//! after its path, for `` elements outside the clip path. +//! //! Characters keep SVG's per-character `x`/`y`/`dx`/`dy`/`rotate` lists: a character with an //! absolute `x` that moves down by at least half a line starts a new line (the leading becomes the //! line spacing), other vertical moves become baseline shift, horizontal moves become kerning (an @@ -166,14 +173,21 @@ pub(super) struct PendingText { pub(super) struct TextSlots { prefix: String, pub texts: Vec, + /// The transform between a clip placeholder path's user space and its text's, by placeholder + /// id, where the placeholder leaves it out (see [`clip_uses`]). + inner: HashMap, + /// The glyph outlines of each text laid out so far, in text space, by index. + outlines: RefCell>>, } impl TextSlots { - /// The text a placeholder's main path stands for: by its id, or inside `` instances - /// (where usvg drops ids) by its colour (the index) and its rectangle. + /// The text a placeholder's main path stands for: by its id (`{prefix}{index}`, or + /// `{prefix}{index}-{k}` for the clip placeholders), or inside `` instances (where usvg + /// drops ids) by its colour (the index) and its rectangle. pub fn find(&self, p: &usvg::Path) -> Option { if !p.id().is_empty() { - return p.id().strip_prefix(&self.prefix)?.parse().ok(); + let rest = p.id().strip_prefix(&self.prefix)?; + return rest.split_once('-').map_or(rest, |(i, _)| i).parse().ok(); } let Some(usvg::Paint::Color(c)) = p.fill().map(|f| f.paint()) else { return None }; if p.stroke().is_some() || p.data().segments().count() != 5 { @@ -185,6 +199,23 @@ impl TextSlots { let near = |a: f32, b: f64| (a as f64 - b).abs() <= 1e-3 * b.abs().max(1.0); (near(b.left(), m.x0) && near(b.top(), m.y0) && near(b.right(), m.x1) && near(b.bottom(), m.y1)).then_some(i) } + + /// The transform from the user space of clip placeholder path `p` to the space of the text it + /// stands for (the identity unless [`clip_uses`] left the text's transform out of it). + pub fn inner(&self, p: &usvg::Path) -> Affine { + self.inner.get(p.id()).copied().unwrap_or(Affine::IDENTITY) + } + + /// The glyph outlines of text `i` in text space, laid out once however many clips use it. + pub fn outlines(&self, i: usize) -> Option> { + if let Some(bp) = self.outlines.borrow().get(&i) { + return Some(bp.clone()); + } + let t = self.texts.get(i)?; + let bp = Rc::new(vectorcraft_text::layout(FontDb::global(), &t.obj).to_bezpath()); + self.outlines.borrow_mut().insert(i, bp.clone()); + Some(bp) + } } /// Replace every `` usvg would draw by a placeholder group (see the module docs). @@ -200,10 +231,11 @@ pub(super) fn prepare<'s>( while xml.descendants().any(|n| n.attribute("id").is_some_and(|id| id.starts_with(&prefix))) { prefix.insert(0, '_'); } - let mut slots = TextSlots { prefix, texts: vec![] }; + let mut slots = TextSlots { prefix, ..TextSlots::default() }; let style_xml = original_xml.unwrap_or(xml); let cx = Ctx::new(style_xml, dpi); - let mut out = String::new(); + // The replacements, in source order: each element's range and what takes its place. + let mut edits: Vec<(Range, String)> = vec![]; let mut copied = 0; let is_text = |n: &XNode| { n.is_element() @@ -211,52 +243,227 @@ pub(super) fn prepare<'s>( && n.tag_name().namespace() == Some(SVG_NS) && !n.ancestors().any(|a| a.has_tag_name("style")) }; + // The slot of each text, by element, for the `` elements in clip paths below. + let mut slot_of = HashMap::new(); for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) { let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); - if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") { + // CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement): + // text is clip path content only as a child of the ``. + let clip_child = in_clip_path(t); + if !clip_child && t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") { warnings.push(format!("{label} in a clip path ignored")); continue; } let r = t.range(); - // The element's qualified name (`text` or `svg:text`), so the placeholder lands in the same - // namespace; skip anything whose source range isn't the element itself (entity expansions). - let Some(qname) = svg - .get(r.start..r.end) - .and_then(|s| s.strip_prefix('<')) - .map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or("")) - else { - continue; - }; + let Some(q) = qualifier(svg, t) else { continue }; let flowed = t.tag_name().name() == "flowRoot"; - let Some(q) = qname.strip_suffix(t.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) else { continue }; if cx.css.own(style_t, "display").as_deref() == Some("none") || r.start < copied { continue; } let built = if flowed { cx.build_flow(style_t, &label, warnings) } else { cx.build(style_xml, style_t, &label, warnings) }; let Some((text, paints)) = built else { continue }; - out.push_str(&svg[copied..r.start]); - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, slots.texts.len(), text.marker, &paints); + let mut out = String::new(); + let index = slots.texts.len(); + if clip_child { + let id = format!("{}{index}-c", slots.prefix); + let props = clip_props(&cx.css, Some(style_t), None); + clip_placeholder(&mut out, q, &id, &own_transform(&cx.css, style_t), &props, t, text.marker); + // A `` elsewhere can still show the text by its id: the clip path leaves out this + // group (a group isn't clip path content), but the `` finds it. + if t.attribute("id").is_some() { + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + } + } else { + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + } copied = r.end; + edits.push((r, out)); + slot_of.insert(t.id(), index); slots.texts.push(text); } if slots.texts.is_empty() { return (Cow::Borrowed(svg), slots); } - out.push_str(&svg[copied..]); + clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings); + edits.sort_by_key(|(r, _)| r.start); + let mut out = String::with_capacity(svg.len()); + let mut copied = 0; + for (r, s) in edits { + if r.start < copied { + continue; + } + out.push_str(svg.get(copied..r.start).unwrap_or("")); + out.push_str(&s); + copied = r.end; + } + out.push_str(svg.get(copied..).unwrap_or("")); (Cow::Owned(out), slots) } +/// Is `n` a child of a ``? +fn in_clip_path(n: XNode) -> bool { + n.parent_element().is_some_and(|p| p.tag_name().name() == "clipPath" && p.tag_name().namespace() == Some(SVG_NS)) +} + +/// The namespace prefix of element `n` as the source writes it (empty, or `svg:` and the like), so +/// what replaces it lands in the same namespace. `None` when its source range isn't the element +/// itself (entity expansions). +fn qualifier<'s>(svg: &'s str, n: XNode) -> Option<&'s str> { + let r = n.range(); + let qname = svg + .get(r.start..r.end) + .and_then(|s| s.strip_prefix('<')) + .map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""))?; + qname.strip_suffix(n.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) +} + +/// Replace each `` in a clip path that shows a text by the clip placeholder of that text, +/// placed by the `` (usvg drops ids and paints clip path content black, so the text a +/// placeholder group stands for couldn't be found again inside one). +#[allow(clippy::too_many_arguments)] +fn clip_uses( + svg: &str, + xml: &roxmltree::Document, + style_xml: &roxmltree::Document, + cx: &Ctx, + slot_of: &HashMap, + slots: &mut TextSlots, + edits: &mut Vec<(Range, String)>, + warnings: &mut Vec, +) { + let is_use = |n: &XNode| n.is_element() && n.tag_name().name() == "use" && n.tag_name().namespace() == Some(SVG_NS) && in_clip_path(*n); + if !xml.descendants().any(|n| is_use(&n)) { + return; + } + let (ids, style_ids) = (first_ids(xml), first_ids(style_xml)); + for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() { + let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; + let Some(t) = ids.get(id) else { continue }; + let Some(&i) = slot_of.get(&t.id()) else { continue }; + let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let Some(q) = qualifier(svg, u) else { continue }; + // An undisplayed `` adds nothing to the clip (usvg leaves it out as it is). + if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") { + continue; + } + let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()); + let em = cx.font_size(style_u); + let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); + let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); + let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u)); + let text_xf = match style_t { + Some(s) => own_transform(&cx.css, s), + None => t.attribute("transform").unwrap_or("").to_string(), + }; + // One element can carry one clip path. The text's own clip path is in the text's user space + // and wins; a clip path of the `` is in the ``'s, so the text's transform moves + // to the importer instead. + let clipped = |n: XNode| cx.css.own(n, "clip-path").is_some_and(|v| v.trim() != "none"); + let id = format!("{}{i}-{k}", slots.prefix); + let transform = if style_t.is_some_and(clipped) { + if clipped(style_u) { + let label = u.attribute("id").map_or_else(|| "a use of a text".to_string(), |id| format!("'{id}'")); + warnings.push(format!("clip path of {label} in a clip path ignored: the text's own applies")); + } + format!("{placed} {text_xf}") + } else { + slots.inner.insert(id.clone(), parse_transform(Some(&text_xf))); + placed + }; + let props = clip_props(&cx.css, style_t, Some(style_u)); + let mut out = String::new(); + clip_placeholder(&mut out, q, &id, &transform, &props, u, marker); + // Another `` can still show this one by its id: a copy of it stays in a ``, which + // the clip path leaves out, with the properties style rules gave it written out. + if u.attribute("id").is_some() { + use_copy(&mut out, q, &cx.css, u, style_u); + } + edits.push((u.range(), out)); + } +} + +/// Write a copy of `` `u` in a ``: its attributes in no namespace, its link as `href`, +/// and the properties set on it by rules and its `style` (from `style_u`) as its `style`. +fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) { + let _ = write!(out, "<{q}defs><{q}use"); + for a in u.attributes().filter(|a| a.namespace().is_none() && !matches!(a.name(), "style" | "href")) { + let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value())); + } + if let Some(h) = href(u) { + let _ = write!(out, " href=\"{}\"", xml_escape(h)); + } + let declared = css.declared(style_u); + let mut props: Vec<_> = declared.iter().collect(); + props.sort(); + let style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect(); + let _ = write!(out, " style=\"{}\"/>", xml_escape(&style)); +} + +/// The identity transform, as a `transform` value usvg reads. +const IDENTITY: &str = "matrix(1 0 0 1 0 0)"; + +/// The transform of element `n`: a `transform` declaration in a rule or its `style` (which the +/// prepass has written in SVG syntax), else its `transform` attribute; empty when it has none. +fn own_transform(css: &Styles, n: XNode) -> String { + css.own(n, "transform").filter(|v| !v.eq_ignore_ascii_case("none")).unwrap_or_default() +} + +/// Each id of `doc` with the first element that has it (a reference goes to that one, as in usvg). +fn first_ids<'a, 'i>(doc: &'a roxmltree::Document<'i>) -> HashMap<&'a str, XNode<'a, 'i>> { + let mut ids = HashMap::new(); + for n in doc.descendants() { + if let Some(id) = n.attribute("id") { + ids.entry(id).or_insert(n); + } + } + ids +} + +/// The properties of clip path content that the placeholder of text `t` keeps, as a `style` value: +/// its `clip-path` (or that of the `` `u` showing it, when the text has none), and its +/// computed `clip-rule` and `visibility`, inherited from `u` when there is one. Every value is +/// written out, so no style rule that matches the placeholder path applies to it. +fn clip_props(css: &Styles, t: Option, u: Option) -> String { + let set = |n: XNode, p: &str| css.own(n, p).filter(|v| v.trim() != "inherit"); + let clip = |n: XNode| css.own(n, "clip-path").filter(|v| v.trim() != "none"); + let mut style = format!("clip-path:{};", t.and_then(clip).or_else(|| u.and_then(clip)).unwrap_or_else(|| "none".into())); + for (p, initial) in [("clip-rule", "nonzero"), ("visibility", "visible")] { + let v = t.and_then(|t| set(t, p)).or_else(|| u.or(t).and_then(|n| css.prop(n, p))).unwrap_or_else(|| initial.into()); + let _ = write!(style, "{p}:{v};"); + } + style +} + +/// Write the placeholder of a text in a clip path: a path over the text's bounds `b` with id `id`, +/// transform `transform`, properties `props` ([`clip_props`]) and the conditions of element `n`. +/// usvg drops a group in a clip path and paints clip path content black whatever its fill, so the +/// placeholder is a path, and the importer finds it by its id ([`TextSlots::find`]). +fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props: &str, n: XNode, b: Rect) { + let _ = write!(out, "<{q}path id=\"{}\"", xml_escape(id)); + for a in ["systemLanguage", "requiredFeatures", "requiredExtensions"] { + if let Some(v) = n.attribute(a) { + let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); + } + } + // In `style`, so no rule that matches the path moves it. + let transform = if transform.trim().is_empty() { IDENTITY } else { transform }; + let _ = write!(out, " style=\"transform:{};{}display:inline\"", xml_escape(transform), xml_escape(props)); + let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0); +} + /// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`. #[allow(clippy::too_many_arguments)] fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { let _ = write!(out, "<{q}g"); - for a in ["id", "transform", "systemLanguage", "requiredFeatures", "requiredExtensions"] { + for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] { if let Some(v) = t.attribute(a) { let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); } } - // The text's own non-inherited properties, overriding any rule that matches the ``. - let mut style = String::new(); + // The text's own non-inherited properties, overriding any rule that matches the ``. The + // transform too: a `transform` declaration in a rule or `style` beats the attribute. + let transform = own_transform(css, style_t); + let mut style = format!("transform:{};", if transform.is_empty() { IDENTITY } else { &transform }); for (p, initial) in [("opacity", "1"), ("clip-path", "none"), ("mask", "none"), ("filter", "none"), ("mix-blend-mode", "normal"), ("isolation", "auto")] { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a3ad7a069..8a447f6c3 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -78,8 +78,11 @@ //! 64 periods). //! * `` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG //! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link. -//! * Text lines after the first start at the first line's x; text in a clip path is ignored; -//! absolute positions inside type on a path are ignored; vertical text sets every glyph sideways. +//! * Text lines after the first start at the first line's x; absolute positions inside type on a +//! path are ignored; vertical text sets every glyph sideways. +//! * A `` in a ``, or a `` there that shows one, clips by its glyph outlines. +//! When a clip path holds only one text, and that text has no clip path of its own and no +//! even-odd clip rule, the clip group's clipping path is the text as live type that paints nothing. //! //! ## Import text //! diff --git a/crates/svg/tests/it/clip_text.rs b/crates/svg/tests/it/clip_text.rs new file mode 100644 index 000000000..07773dd5f --- /dev/null +++ b/crates/svg/tests/it/clip_text.rs @@ -0,0 +1,429 @@ +//! SVG import of clip paths made of text (CSS Masking Module Level 1 §6.1, +//! https://www.w3.org/TR/css-masking-1/#ClipPathElement: `` and `` are clip path +//! content). + +use vectorcraft_color::Paint; +use vectorcraft_doc::{Document, Node, NodeKind, TextKind}; +use vectorcraft_geom::kurbo::Shape; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_svg::{ExportOptions, export, import, import_with_report}; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} + +fn open(body: &str) -> Document { + import(&svg(body)).unwrap_or_else(|e| panic!("{e}")) +} + +/// The top-level objects of the first layer. +fn top(d: &Document) -> &[std::sync::Arc] { + d.layers[0].children().unwrap() +} + +/// The clip group `n` is, with its clipping path and the art it clips. +fn clip_group(n: &Node) -> (&Node, &[std::sync::Arc]) { + let NodeKind::Group { children, clip: true } = &n.kind else { panic!("not a clip group: {:?}", n.kind) }; + let (first, rest) = children.split_first().unwrap(); + (first, rest) +} + +/// The bounds of the region a clipping path clips to. +fn clip_bounds(clip: &Node) -> Rect { + let (bp, _) = vectorcraft_effects::clip_outline(clip).expect("the clipping path clips to something"); + bp.bounding_box() +} + +fn near(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +#[test] +fn a_text_clip_path_clips_by_the_text_as_live_type() { + let d = open( + r##"HI + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 1, "{objects:?}"); + let (clip, art) = clip_group(&objects[0]); + assert_eq!(art.len(), 1); + assert_eq!(art[0].name.as_deref(), Some("art")); + // The clipping path is the text object with no fill or stroke, as after Make Clipping Mask. + let NodeKind::Text(t) = &clip.kind else { panic!("the clipping path is {:?}", clip.kind) }; + assert_eq!(t.plain_text(), "HI"); + assert!(t.runs.iter().all(|r| r.style.fill == Paint::None && r.style.stroke == Paint::None)); + assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size); + assert!(t.xf.translation().to_point().distance(Point::new(20.0, 150.0)) < 1e-9, "{:?}", t.xf); + let p = clip.clip_paint(); + assert!(p.fill.is_none() && p.stroke.is_none(), "a clip path paints nothing"); + // It clips to the glyphs: inside the line, not the whole rectangle. + let b = clip_bounds(clip); + assert!(b.x0 >= 20.0 - 1e-6 && b.x1 < 220.0 && b.y1 <= 150.0 + 1e-6 && b.y0 > 50.0 && b.width() > 40.0, "{b:?}"); +} + +#[test] +fn text_and_shapes_in_one_clip_path_clip_to_their_union() { + let d = open( + r##"O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 2, "the rectangle and the glyph outlines: {shapes:?}"); + let rect = shapes[0].0.bounding_box(); + assert!(near(rect.x0, 300.0, 1e-6) && near(rect.y1, 50.0, 1e-6), "{rect:?}"); + let glyphs = shapes[1].0.bounding_box(); + assert!(glyphs.x0 >= 20.0 - 1e-6 && glyphs.x1 < 120.0 && glyphs.y1 < 160.0 && glyphs.y0 > 50.0, "{glyphs:?}"); + let b = clip_bounds(clip); + assert!(near(b.x1, 350.0, 1e-6) && near(b.y0, 10.0, 1e-6), "{b:?}"); +} + +#[test] +fn a_text_clip_path_keeps_its_units_and_transforms() { + // The clip path's box units and the text's own transform place the type. + let d = open( + r##" + I + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let b = clip_bounds(clip); + // The box maps x 0.5…1 to 200…300 and y 0…1 to 100…200. + assert!(b.x0 >= 200.0 - 1e-6 && b.x1 <= 300.0 && b.y0 >= 100.0 && b.y1 <= 180.0 + 1e-6 && b.height() > 30.0, "{b:?}"); + // The clip path's transform moves it, as it moves a shape in it. + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(120.0, 170.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_text_clip_path_with_a_clip_rule_or_a_clip_of_its_own_clips_by_its_outlines() { + // The text's own clip path cuts its glyphs: the clip keeps their part inside the rectangle. + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(!matches!(clip.kind, NodeKind::Text(_)), "outlines, as the clip of the text cuts them"); + let b = clip_bounds(clip); + assert!(b.y1 <= 120.0 + 1e-6 && b.y0 > 50.0 && b.x0 >= 20.0 - 1e-6, "{b:?}"); + // An even-odd text clip keeps its rule on the outlines. + let d = open( + r##"O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].1, vectorcraft_geom::FillRule::EvenOdd); +} + +#[test] +fn tspans_and_type_on_a_path_clip_too() { + let d = open( + r##"AB + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.plain_text().contains('A') && t.plain_text().contains('B'), "{:?}", t.plain_text()); + let b = clip_bounds(clip); + assert!(b.y0 < 80.0 && b.y1 > 150.0, "both lines clip: {b:?}"); + + let d = open( + r##" + Around + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind); + assert!(clip_bounds(clip).width() > 50.0); +} + +#[test] +fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(20.0, 150.0)) < 1e-9, "{:?}", t.xf); + // Font-relative positions count in the ``'s font size; an undisplayed `` adds nothing. + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("only the displayed use: {:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(80.0, 120.0)) < 1e-9, "{:?}", t.xf); + // The same text drawn as well as used in a clip path. + let d = open( + r##"HI + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + assert!(matches!(objects[0].kind, NodeKind::Text(_))); + let (clip, _) = clip_group(&objects[1]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 2, "{shapes:?}"); + let glyphs = shapes[0].0.bounding_box(); + assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}"); +} + +#[test] +fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() { + // The text's transform moves its glyphs to x 200…; the ``'s clip keeps x < 250 of its own + // user space, which the text's transform doesn't move. + let d = open( + r##" + HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(b.x0 >= 200.0 - 1e-6 && near(b.x1, 250.0, 1e-6), "{b:?}"); + // Without a clip on the ``, the text's transform still places the type. + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(200.0, 160.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_css_transform_places_a_text_in_a_clip_path() { + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(120.0, 170.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn css_transforms_place_a_use_of_a_text_in_a_clip_path() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(t.xf.translation().to_point().distance(Point::new(25.0, 150.0)) < 1e-9, "{:?}", t.xf); +} + +#[test] +fn a_use_refers_to_the_first_element_with_its_id() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(!matches!(clip.kind, NodeKind::Text(_)), "the rectangle clips: {:?}", clip.kind); + let b = clip_bounds(clip); + assert!(near(b.x0, 300.0, 1e-6) && near(b.x1, 350.0, 1e-6) && near(b.y1, 50.0, 1e-6), "{b:?}"); +} + +#[test] +fn a_text_in_a_clip_path_can_be_shown_by_a_use_elsewhere() { + let d = open( + r##"HI + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + let (clip, _) = clip_group(&objects[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let mut shown = vec![]; + objects[1].walk(&mut |n: &Node| { + if let NodeKind::Text(t) = &n.kind { + shown.push(t.plain_text()); + } + }); + assert_eq!(shown, ["HI"], "the use shows the text: {:?}", objects[1]); +} + +#[test] +fn a_use_in_a_clip_path_can_be_shown_by_another_use() { + let d = open( + r##"HI + + + "##, + ); + let objects = top(&d); + assert_eq!(objects.len(), 2, "{objects:?}"); + let (clip, _) = clip_group(&objects[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind); + let mut shown = vec![]; + objects[1].walk(&mut |n: &Node| match &n.kind { + NodeKind::Text(t) => shown.push(t.plain_text()), + NodeKind::Path { .. } => shown.push("a path".into()), + _ => {} + }); + assert_eq!(shown, ["HI"], "the other use shows the text: {:?}", objects[1]); + // A rule that styles the `` where it stands still applies to what the other use shows. + let d = open( + r##" + HI + + + "##, + ); + let objects = top(&d); + let mut at = vec![]; + objects[1].walk(&mut |n: &Node| { + if let NodeKind::Text(t) = &n.kind { + at.push(t.xf.translation().to_point()); + } + }); + assert!(at.len() == 1 && at[0].distance(Point::new(60.0, 200.0)) < 1e-9, "{at:?}"); +} + +#[test] +fn style_rules_for_paths_do_not_reach_a_text_in_a_clip_path() { + let d = open( + r##" + HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "the visible, non-zero text: {:?}", clip.kind); + // The text's own values, and those it inherits, still apply. + let d = open( + r##" + O + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let shapes = clip.clip_shapes(None); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].1, vectorcraft_geom::FillRule::EvenOdd); +} + +#[test] +fn a_hidden_text_alone_in_a_clip_path_is_not_live_type() { + let d = open( + r##"HI + "##, + ); + let objects = top(&d); + let clips = objects.iter().filter_map(|n| match &n.kind { + NodeKind::Group { children, clip: true } => children.first(), + _ => None, + }); + for clip in clips { + assert!(!matches!(clip.kind, NodeKind::Text(_)), "a hidden text clips nothing: {:?}", clip.kind); + } +} + +#[test] +fn a_text_left_out_by_its_conditions_clips_nothing() { + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(near(b.x1, 10.0, 1e-6) && near(b.y1, 10.0, 1e-6), "only the rectangle: {b:?}"); +} + +#[test] +fn a_text_clip_on_a_symbol_instance_cuts_art_between_the_letters() { + // The art sits in the space between the two letters, inside the text's box: the glyphs cut it, + // so it stays clipped art instead of becoming an instance without the clip. + let body = |clip: &str| { + format!( + r##" + {clip} + "## + ) + }; + let instances = |d: &Document| { + let mut n = 0; + d.walk(|node| n += usize::from(matches!(node.kind, NodeKind::SymbolInstance { .. }))); + n + }; + let d = open(&body(r#"I I"#)); + assert_eq!(instances(&d), 0, "{:?}", top(&d)[0].kind); + assert!(matches!(top(&d)[0].kind, NodeKind::Group { clip: true, .. })); + // A clip around the art leaves it whole: an instance. + let d = open(&body(r#""#)); + assert_eq!(instances(&d), 1); +} + +#[test] +fn a_clip_path_clipped_by_a_text_clip_path_nests() { + let d = open( + r##"HI + + "##, + ); + let (clip, art) = clip_group(&top(&d)[0]); + assert!(matches!(clip.kind, NodeKind::Text(_)), "the outermost clip is the text: {:?}", clip.kind); + let (inner, _) = clip_group(&art[0]); + assert!(matches!(inner.kind, NodeKind::Path { clipping: true, .. }), "{:?}", inner.kind); +} + +#[test] +fn hidden_text_in_a_clip_path_clips_nothing() { + let d = open( + r##"HI + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let b = clip_bounds(clip); + assert!(near(b.x1, 10.0, 1e-6) && near(b.y1, 10.0, 1e-6), "only the rectangle: {b:?}"); +} + +#[test] +fn text_further_down_in_a_clip_path_is_still_not_clip_path_content() { + let body = r##"No + "##; + let (_, warnings) = import_with_report(&svg(body)).unwrap(); + assert!(warnings.iter().any(|w| w.contains("in a clip path ignored")), "{warnings:?}"); +} + +#[test] +fn a_text_clip_survives_a_round_trip_through_svg_export() { + let d = open( + r##"HI + "##, + ); + let before = clip_bounds(clip_group(&top(&d)[0]).0); + let again = import(&export(&d, &ExportOptions::default())).unwrap(); + let mut clips = vec![]; + again.walk(|n| { + if let NodeKind::Group { children, clip: true } = &n.kind { + clips.push(children[0].clone()); + } + }); + assert_eq!(clips.len(), 1); + let after = clip_bounds(&clips[0]); + let tol = 0.01 * before.width().max(before.height()); + assert!( + near(after.x0, before.x0, tol) && near(after.x1, before.x1, tol) && near(after.y0, before.y0, tol) && near(after.y1, before.y1, tol), + "{before:?} → {after:?}" + ); +} diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 7d0e08c0b..a30bc193b 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -108,6 +108,19 @@ fn text_keeps_its_z_order_between_shapes() { assert!(t.xf.translation().to_point().distance(Point::new(5.0, 20.0)) < 1e-9); } +#[test] +fn a_css_transform_places_text() { + // A `transform` declaration in a rule or `style` beats the attribute, as on shapes. + for body in [ + r#"Moved"#, + r#"Moved"#, + ] { + let d = open(body); + let t = only_text(&d); + assert!(t.xf.translation().to_point().distance(Point::new(10.0, 40.0)) < 1e-9, "{body}: {:?}", t.xf); + } +} + #[test] fn text_keeps_its_parent_group_clip_and_mask() { let d = open( diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index 3605bd014..09c175a37 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -4,6 +4,7 @@ mod basic_shapes; mod blend_export; +mod clip_text; mod colorized_images; mod colour_glyphs; mod container_export; From 1851f8769a34fd305bad2c039378e9cd03debf15 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:05:16 -0800 Subject: [PATCH 19/40] Glyphs: text.setGlyphAlternate with gid null reports the glyph drawn With no glyph choice left on the character, text.setGlyphAlternate returned the font's default glyph for it: 0 for a character the font lacks, which a fallback font draws, and the lowercase glyph for a letter under All Caps. It now reports the glyph the character is drawn with, as text.glyphAlternates does. --- crates/engine/src/cmd/textedit.rs | 20 ++++++++----- crates/engine/src/tests/textedit.rs | 44 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 689a73ea0..86cc05081 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1276,19 +1276,25 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { // out (overflowing text) is shaped alone in its writing direction, with the rest of its grapheme // in the grapheme's own styles. let plain = t.plain_text(); - let unchosen = holder.as_ref().filter(|_| !styled).and_then(|_| { - let mut probe = t.clone(); - edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); - own_glyph(&plain, &lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0) - }); + let mut probe = t.clone(); + edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); + let probe_lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); + let unchosen = holder + .as_ref() + .filter(|_| !styled) + .and_then(|_| own_glyph(&plain, &probe_lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0)); let substituted = unchosen.is_some_and(|g| g != default); let choice = match gid.filter(|g| *g != default || substituted) { None => None, Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)), Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), }; - let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid); + // Without a choice, the glyph drawn in whatever face draws the character, as + // `text.glyphAlternates` reports it (a fallback face's glyph, the capital of All Caps). + let drawn = match &choice { + Some(c) => c.gid, + None => drawn_glyph(&plain, &probe_lay, ch, &bytes, &face).or_else(|| shaped_alone(&probe, ch, &bytes, &face).1).unwrap_or(default), + }; // The run holding the character. The request changes nothing when the choice is the one the // character's run holds and that run holds the character alone; otherwise the edit splits the // run, so that the following text does not take the character into a ligature. diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index e584e3e32..69f7bc644 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -3436,6 +3436,50 @@ fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { assert_eq!(query["chosen"], json!(glyph.gid), "an overflowing character reports the fallback glyph it is drawn with"); } +/// `text.setGlyphAlternate` with `gid: null` reports the glyph the character is drawn with, as +/// `text.glyphAlternates` does: the fallback face's glyph for a character the font lacks, laid out +/// or overflowing, and the capital that All Caps draws for an a. +#[test] +fn a_glyph_choice_taken_away_reports_the_glyph_drawn() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let mut s = session(); + let reset = |s: &mut Session, id: NodeId, start: usize| { + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": null})).unwrap()["gid"].clone() + }; + let chosen = |s: &mut Session, id: NodeId, start: usize| { + s.execute("text.glyphAlternates", &json!({"id": id.0, "start": start})).unwrap()["chosen"].clone() + }; + + let ch = 'Ƃ'; + assert_eq!(face.glyph_for(ch), 0); + let point = json!({"x": 0, "y": 0, "text": ch.to_string(), "font": alternates_family(true)}); + let id = NodeId(s.execute("text.create", &point).unwrap()["id"].as_u64().unwrap()); + let drawn = vectorcraft_text::layout(db, &obj(&s, id)).glyphs[0].gid; + assert_ne!(drawn, 0); + assert_eq!(reset(&mut s, id, 0), json!(drawn), "the fallback glyph"); + assert_eq!(chosen(&mut s, id, 0), json!(drawn)); + + let area = json!({"x": 0, "y": 300, "text": ch.to_string(), "font": alternates_family(true), "area": {"width": 200, "height": 2}}); + let boxed = NodeId(s.execute("text.create", &area).unwrap()["id"].as_u64().unwrap()); + assert!(vectorcraft_text::layout(db, &obj(&s, boxed)).glyphs.is_empty(), "the text overflows"); + assert_eq!(reset(&mut s, boxed, 0), json!(drawn), "the overflowing character's fallback glyph"); + + let caps = NodeId( + s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xaay", "size": 20, "font": alternates_family(true)})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("text.setRangeStyle", &json!({"id": caps.0, "start": 0, "end": 4, "allCaps": true})).unwrap(); + let drawn = vectorcraft_text::layout(db, &obj(&s, caps)).glyphs.iter().find(|g| g.byte == 1).expect("the a is laid out").gid; + assert_ne!(drawn, face.glyph_for('a'), "All Caps draws the a as A"); + assert_eq!(reset(&mut s, caps, 1), json!(drawn), "the capital drawn"); + assert_eq!(chosen(&mut s, caps, 1), json!(drawn)); +} + /// A fallback face draws Ka with the vowel sign I in one cluster, the vowel sign first. The glyph /// reported for Ka is the fallback face's glyph for Ka, laid out or overflowing, not the vowel /// sign's. From d13f7324f9eb81878317fd92a9b7004a756f7c15 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:25:58 -0800 Subject: [PATCH 20/40] Dimension tool: a dimension selected with its art stays live through Expand and Outline Stroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dimension selected with the art it measures became a plain group of paths and text when Object › Expand, Object › Expand Appearance or Object › Path › Outline Stroke ran, so it no longer redrew when moved or restyled. These commands now leave such a dimension's lines and label as they are, also inside selected groups without fills, strokes or effects of their own, and change only the other art. The dimension keeps its export assets and variable bindings. When the new art no longer fits the dimension's link, for example because the outlined stroke has other anchors, the dimension stays where it was, no longer linked, as it does when its art is deleted. Undo links it again. When the dimension is all the command would change, the command changes nothing and reports the error it gives for a selection with nothing to change. Object › Expand… is no longer dimmed for the label of such a dimension. A dimension selected without the art it measures, a part of one, and one inside a selected group with an appearance of its own still become plain groups, and Type › Create Outlines still outlines the label. Docs: docs/dimensions.md, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/dimension.rs | 168 ++++++++++++++-- crates/engine/src/cmd/effectcmd.rs | 35 +++- crates/engine/src/cmd/expand.rs | 46 +++-- crates/engine/src/cmd/pathops.rs | 16 +- crates/engine/src/cmd/typecmd.rs | 5 +- crates/engine/tests/it/dimensions.rs | 249 +++++++++++++++++++++--- crates/engine/tests/plugin_safe_mode.rs | 40 ++++ docs/dimensions.md | 29 ++- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 10 files changed, 510 insertions(+), 84 deletions(-) diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index fa2137c9d..e1b88d5d9 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -272,9 +272,9 @@ impl EndedLink { } /// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it -/// measured. A command that replaces art calls this before it changes anything, since replacing -/// the art a dimension measures would otherwise leave the dimension unlinked, and then -/// [`restore_unchanged`]. +/// measured. A command that replaces art calls this before it changes anything and +/// [`restore_unchanged`] after, so that a dimension whose own art the command changed stays a +/// plain group. pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec { let mut ended = Vec::new(); for id in reached(doc, roots) { @@ -287,18 +287,156 @@ pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec ended } -/// The dimensions in `ended` whose art is as it was are live again. -pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec) { +/// The dimensions in `ended` whose own art is as it was are live again. One whose measured art the +/// command changed from `before` so that the art no longer fits its link stays where it was, no +/// longer linked, as [`refresh`] leaves a dimension whose art was deleted. +pub(crate) fn restore_unchanged(before: &Document, doc: &mut Document, ended: Vec) { for e in ended { - if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) - && let Some(n) = doc.node_mut(e.id) - { - n.extras_mut().dimension = Some(Box::new(e.recipe)); + if !doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) { + continue; + } + let recipe = if e.source_kept(before, doc) { e.recipe } else { unlinked(&e.recipe) }; + if let Some(n) = doc.node_mut(e.id) { + n.extras_mut().dimension = Some(Box::new(recipe)); doc.dimensions = true; } } } +/// `old` where it was, no longer linked. A move it had on its own and not yet drawn is kept when +/// the moved dimension is valid. +fn unlinked(old: &Dimension) -> Dimension { + let mut unlinked = old.clone(); + unlinked.source = None; + unlinked.moved = None; + if let Some(v) = old.moved { + let mut shifted = unlinked.clone(); + (shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v); + if shifted.validate().is_ok() { + unlinked = shifted; + } + } + unlinked +} + +/// The whole dimensions under `roots` that are selected together with art they measure (art under +/// `roots`), each one a root or held by groups without fills, strokes or effects of their own. +/// Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave their +/// lines and label as they are. A group with an appearance of its own changes everything it holds +/// when it expands, its dimensions included. +pub(crate) fn with_their_art(doc: &Document, roots: &[NodeId]) -> Vec { + if !doc.dimensions { + return Vec::new(); + } + let mut found = Vec::new(); + for root in roots { + let Some(n) = doc.node(*root) else { continue }; + n.walk(&mut |c| { + if let Some(source) = c.extras().dimension.as_ref().and_then(|d| d.source.as_ref()) { + found.push((*root, c.id, source.nodes())); + } + }); + } + let under_roots = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().any(|a| roots.contains(a))); + found + .into_iter() + .filter(|(root, id, _)| { + let Some(ancestry) = doc.ancestry(*id) else { return false }; + let from = ancestry.iter().position(|a| a == root).unwrap_or(ancestry.len()); + ancestry.get(from..ancestry.len().saturating_sub(1)).unwrap_or_default().iter().all(|a| doc.node(*a).is_some_and(plain_group)) + }) + .filter(|(_, _, measured)| measured.iter().any(|m| under_roots(*m))) + .map(|(_, id, _)| id) + .collect() +} + +/// A group or layer without fills, strokes or effects of its own. +fn plain_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && n.appearance.items.is_empty() && n.appearance.effects.is_empty() +} + +/// A dimension [`lift`] took out of the document, with its place, the export assets that list it or +/// its parts (with their places in the asset list) and its parts' variable bindings. +pub(crate) struct Lifted { + parent: Option, + index: usize, + node: Arc, + assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>, + bindings: Vec<(NodeId, std::collections::BTreeSet)>, +} + +impl Lifted { + pub(crate) fn id(&self) -> NodeId { + self.node.id + } +} + +/// Takes the [`with_their_art`] dimensions under `roots` out of `doc`, so a command that replaces +/// art does not reach them; [`put_back`] puts them back. +pub(crate) fn lift(doc: &mut Document, roots: &[NodeId]) -> Vec { + let mut lifted = Vec::new(); + for id in with_their_art(doc, roots) { + let Some((parent, index, _)) = doc.position(id) else { continue }; + let mut parts = std::collections::BTreeSet::new(); + if let Some(n) = doc.node(id) { + n.walk(&mut |c| { + parts.insert(c.id); + }); + } + let assets = doc.assets.iter().enumerate().filter(|(_, a)| a.nodes.iter().any(|n| parts.contains(n))).map(|(i, a)| (i, a.clone())).collect(); + let bindings = doc.variables.bindings.iter().filter(|(n, _)| parts.contains(n)).map(|(n, b)| (*n, b.clone())).collect(); + if let Ok(node) = doc.remove(id) { + lifted.push(Lifted { parent, index, node, assets, bindings }); + } + } + lifted +} + +/// Each dimension [`lift`] took out, as it was, back in its place in `doc`, with its parts back in +/// the export assets that listed them and their variable bindings back: edits made while it was out +/// let go of them. A dimension whose group is gone goes on top of the first layer. +pub(crate) fn put_back(doc: &mut Document, lifted: Vec) { + for l in lifted.into_iter().rev() { + let parent = if doc.children(l.parent).is_some() { l.parent } else { doc.layers.first().map(|layer| layer.id) }; + let Ok(ch) = doc.children_mut(parent) else { continue }; + let i = if parent == l.parent { l.index.min(ch.len()) } else { ch.len() }; + ch.insert(i, l.node); + doc.dimensions = true; + for (at, saved) in l.assets { + let there: Vec = saved.nodes.iter().copied().filter(|n| doc.node(*n).is_some()).collect(); + if let Some(a) = doc.assets.iter_mut().find(|a| a.id == saved.id) { + for (k, n) in there.iter().enumerate() { + if !a.nodes.contains(n) { + let k = k.min(a.nodes.len()); + a.nodes.insert(k, *n); + } + } + } else if !there.is_empty() { + let at = at.min(doc.assets.len()); + doc.assets.insert(at, vectorcraft_doc::assets::ExportAsset { nodes: there, ..saved }); + } + } + for (n, b) in l.bindings { + doc.variables.bindings.entry(n).or_insert(b); + } + } +} + +/// `doc` without the [`with_their_art`] dimensions under `roots`, to see what a command that leaves +/// them alone would change: `doc` itself when there are none. +pub(crate) fn without_kept<'a>(doc: &'a Document, roots: &[NodeId]) -> std::borrow::Cow<'a, Document> { + let ids = with_their_art(doc, roots); + if ids.is_empty() { + return std::borrow::Cow::Borrowed(doc); + } + let mut view = doc.clone(); + for id in ids { + // A dimension already gone (inside another one removed) needs nothing more. + let _ = view.remove(id); + } + std::borrow::Cow::Owned(view) +} + /// Called inside every edit, so dimensions and their undo history stay in step with the art they /// measure. Returns at once in documents without dimensions. pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { @@ -320,17 +458,7 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { // The art it measured was deleted, or rebuilt with other anchors, or no longer fits // (an ellipse that was a circle): it stays where it was, no longer linked, in this // same undo step, rather than attaching to a different edge. - let mut unlinked = old.clone(); - unlinked.source = None; - unlinked.moved = None; - if let Some(v) = moved { - let mut shifted = unlinked.clone(); - (shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v); - if shifted.validate().is_ok() { - unlinked = shifted; - } - } - if replace(doc, id, unlinked).is_err() + if replace(doc, id, unlinked(&old)).is_err() && let Some(n) = doc.node_mut(id) { // Its recipe cannot be drawn again: its art stays as it is, as a plain group. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index a19455bd5..ade33d4be 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -674,11 +674,19 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> { /// fills, strokes or effects of its own or such members, other objects with effects, or type with /// fills or strokes of its own? pub(crate) fn expandable(n: &Node) -> bool { + expandable_except(n, &[]) +} + +/// [`expandable`], leaving out the objects `skip` lists and what they hold. +pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool { + if skip.contains(&n.id) { + return false; + } match &n.kind { NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false, NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n), NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c)) + !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip)) } NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(), NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), @@ -688,9 +696,11 @@ pub(crate) fn expandable(n: &Node) -> bool { fn can_expand(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - let d = &s.doc().map_err(|e| e.to_string())?.doc; let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?; - if ids.iter().filter_map(|id| d.node(*id)).any(expandable) { + let d = &s.doc().map_err(|e| e.to_string())?.doc; + // A dimension selected with the art it measures is left as it is. + let kept = super::dimension::with_their_art(d, &ids); + if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) { Ok(()) } else { Err("select (or target) objects whose appearance isn't basic".into()) @@ -975,7 +985,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the // geometry it hasn't changed. if vectorcraft_plugins::safe_mode::active() { - let d = &s.doc()?.doc; + // A dimension selected with the art it measures is left as it is. + let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots); let mut seen = std::collections::HashSet::new(); if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); @@ -985,20 +996,24 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let before = d.clone(); - let mut ended = super::dimension::end_links(d, &roots); + // A dimension selected with the art it measures keeps its own lines and label: it is out + // of the document while the art expands. + let lifted = super::dimension::lift(d, &roots); + let kept = !lifted.is_empty(); + let ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut out = vec![]; let threaded = super::threads::frames(d); for id in &roots { expand_node(d, *id, &brushes, &mut out); } - // A dimension whose source was replaced stays plain: its link no longer fits that art. - ended.retain(|e| e.source_kept(&before, d)); - super::dimension::restore_unchanged(d, ended); + super::dimension::put_back(d, lifted); + // A dimension whose source was replaced stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); // Outlined frames of threaded text leave their threads with the text they show; type that // stays type under a new id takes its frame's place. super::threads::leave_threads(d, &threaded); - if out.is_empty() { + if out.is_empty() || (kept && d.layers == before.layers) { return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into())); } super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?; diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index c5c0fbd65..ffb58d9d4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -5,7 +5,8 @@ //! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip //! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art -//! Expand changes becomes a plain group of that art. +//! Expand changes becomes a plain group of that art, except one selected with art it measures, +//! which Expand leaves as it is. use std::collections::BTreeSet; use std::sync::Arc; @@ -43,7 +44,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -55,7 +56,8 @@ pub fn specs() -> Vec { "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", has_selection, |s, _| { - let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + let roots = selected_roots(s)?; + let e = Expandable::of(&super::dimension::without_kept(&s.doc()?.doc, &roots), &roots); let mut info = json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)}); if e.symbols { @@ -74,7 +76,9 @@ pub fn specs() -> Vec { /// (#3089); `object.expand` itself still bakes them. pub fn has_appearance(s: &Session) -> bool { let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false }; - roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable) + // A dimension selected with the art it measures is left as it is. + let kept = super::dimension::with_their_art(&st.doc, &roots); + roots.iter().filter_map(|r| st.doc.node(*r)).any(|n| super::effectcmd::expandable_except(n, &kept)) } /// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it @@ -241,9 +245,18 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { let stroke = switch_or(p, "stroke", true, C)?; let to = GradientTo::parse(p)?; let from = s.doc()?.history.undo.len(); - let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); // Before anything expands: outlining the art a dimension measures would leave the dimension unlinked. let before = s.doc()?.doc.clone(); + let selected = s.doc()?.selection.objects.clone(); + // A dimension selected with the art it measures keeps its own lines and label: it is out of the + // document while the art expands, so no step below reaches it. + let roots = selected_roots(s)?; + let mut lifted = vec![]; + if !super::dimension::with_their_art(&before, &roots).is_empty() { + lifted = s.edit("Expand", |d, _| Ok(super::dimension::lift(d, &roots)))?; + } + let kept: Vec = lifted.iter().map(super::dimension::Lifted::id).collect(); + let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); let mut ended = vec![]; end_dimension_links(s, &mut ended)?; let rev0 = s.doc()?.revision; @@ -339,14 +352,25 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { if s.doc()?.revision == rev0 { return Err(EngineError::Other("Expand: nothing to expand".into())); } - if !ended.is_empty() { - // A dimension whose source Expand replaced stays plain: its link no longer fits that art. - let after = s.doc()?.doc.clone(); - ended.retain(|e| e.source_kept(&before, &after)); - s.edit("Expand", |d, _| { - super::dimension::restore_unchanged(d, std::mem::take(&mut ended)); + if !ended.is_empty() || !lifted.is_empty() { + s.edit("Expand", |d, sel| { + if !lifted.is_empty() { + super::dimension::put_back(d, lifted); + // The dimensions put back follow what Expand made of the art they measure: a + // dimension whose art no longer fits its link stays where it was, no longer linked. + super::dimension::refresh(&before, d)?; + let mut ids = sel.objects.clone(); + let again: Vec = kept.iter().copied().filter(|id| selected.contains(id) && !ids.contains(id)).collect(); + ids.extend(again); + sel.set(ids); + } + // A dimension whose source Expand replaced stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); Ok(()) })?; + if !kept.is_empty() && s.doc()?.doc.layers == before.layers { + return Err(EngineError::Other("Expand: nothing to expand".into())); + } } squash(s, from, "Expand"); Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::>() })) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 82de6fa93..c8453c8c0 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "Outline Stroke", ["Object", "Path"], None, - "{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}", + "{} replace stroked paths with filled outlines; a dimension whose lines are outlined becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines stay as they are, and if the outlined art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, outline_stroke ), @@ -1074,7 +1074,11 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Outline Stroke", |d, sel| { let before = d.clone(); - let mut ended = super::dimension::end_links(d, &roots); + // A dimension selected with the art it measures keeps its own lines: it is out of the + // document while the art is outlined. + let lifted = super::dimension::lift(d, &roots); + let kept = !lifted.is_empty(); + let ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut new_sel = vec![]; let mut changed = 0; @@ -1083,10 +1087,10 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { new_sel.push(id); changed += n; } - // A dimension whose source was outlined stays plain: its link no longer fits that art. - ended.retain(|e| e.source_kept(&before, d)); - super::dimension::restore_unchanged(d, ended); - if changed == 0 { + super::dimension::put_back(d, lifted); + // A dimension whose source was outlined stays where it was, no longer linked. + super::dimension::restore_unchanged(&before, d, ended); + if changed == 0 || (kept && d.layers == before.layers) { return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into())); } sel.set(new_sel.iter().copied()); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 0ddd14594..48959c54e 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -596,6 +596,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { if texts.is_empty() { return Err(EngineError::Other("Create Outlines: select text objects".into())); } + let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone()); let ended = super::dimension::end_links(d, &roots); let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); let mut out = vec![]; @@ -691,7 +692,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { new_sel.push(gid); } } - super::dimension::restore_unchanged(d, ended); + if let Some(before) = &before { + super::dimension::restore_unchanged(before, d, ended); + } // Outlined frames of threaded text leave their threads with the text they show. super::threads::leave_threads(d, &threaded); sel.set(new_sel); diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 934108905..56ee67be1 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -1164,16 +1164,10 @@ fn an_expand_that_changes_no_art_leaves_a_dimension_live() { assert_eq!(art(&s, id), live); } -/// A dimension selected with the art it measures, or held by a selected group, expands the same -/// way: outlining that art does not remove it. +/// A dimension held by a selected group without the art it measures expands as a dimension +/// selected on its own does. #[test] -fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() { - let mut s = session(); - let line = edge(&mut s); - let id = dim(&mut s, line); - run(&mut s, "select.set", json!({"ids":[line, id]})); - run(&mut s, "object.expand", json!({})); - stays_plain_art(&mut s, id); +fn a_dimension_expanded_in_a_group_without_its_art_stays_plain_art() { let mut s = session(); let line = edge(&mut s); let id = dim(&mut s, line); @@ -1183,8 +1177,176 @@ fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() { stays_plain_art(&mut s, id); } -/// Expand replacing only the art a selected dimension measures leaves the dimension's art as it -/// is, without a link to the art that went. +/// `command` on a dimension selected with the art it measures, or on a group holding both, leaves +/// the dimension's own lines and label as they are and replaces only the art: the dimension stays +/// where it was, no longer linked, and one undo links it again. +fn keeps_its_own_lines_and_label(command: &str) { + for grouped in [false, true] { + let context = format!("{command}, grouped: {grouped}"); + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + run(&mut s, "select.set", json!({"ids":[art_id]})); + run(&mut s, "appearance.addStroke", json!({})); + let id = dim(&mut s, art_id); + let live = art(&s, id); + let label = text(&s, id); + let at = bounds(&s, id); + let linked = recipe(&s, id); + let measured = s.doc().unwrap().doc.node(NodeId(art_id)).unwrap().clone(); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + if grouped { + run(&mut s, "object.group", json!({})); + } + let steps = s.doc().unwrap().history.undo.len(); + let result = run(&mut s, command, json!({})); + if command == "effect.expandAppearance" { + let mut own = vec![]; + s.doc().unwrap().doc.node(NodeId(id)).unwrap().walk(&mut |c| own.push(json!(c.id.0))); + assert!(result["ids"].as_array().unwrap().iter().all(|i| !own.contains(i)), "{context}: {result}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{context}: one undo step"); + assert_ne!(s.doc().unwrap().doc.node(NodeId(art_id)), Some(&measured), "{context}: the art is replaced"); + assert_eq!(art(&s, id), live, "{context}: its own lines and label"); + assert_eq!(bounds(&s, id), at, "{context}: where it was"); + stays_unlinked(&mut s, id, &label, &context); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "{context}"); + assert_eq!(recipe(&s, id), linked, "{context}: one undo links it again"); + assert_eq!(s.doc().unwrap().doc.node(NodeId(art_id)), Some(&measured), "{context}"); + } +} + +#[test] +fn expand_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("object.expand"); +} + +#[test] +fn expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("effect.expandAppearance"); +} + +#[test] +fn outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is() { + keeps_its_own_lines_and_label("object.path.outlineStroke"); +} + +/// A selected group whose blur Expand Appearance turns into an image takes the dimension it holds, +/// with the art it measures, into that image. +#[test] +fn a_blurred_group_expanded_takes_its_dimension_into_the_image() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + let id = dim(&mut s, art_id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + let group = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[group]})); + run(&mut s, "effect.apply", json!({"effect":"blur.gaussian"})); + run(&mut s, "effect.expandAppearance", json!({})); + let d = &s.doc().unwrap().doc; + assert!(d.node(NodeId(id)).is_none(), "the dimension is part of the image"); + let mut images = 0; + d.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); + assert_eq!(images, 1); +} + +/// Object › Expand keeps a dimension selected with its art in the export assets that list it and +/// bound to its variables. +#[test] +fn expand_keeps_a_dimension_in_its_asset_and_its_variable_binding() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + let id = dim(&mut s, art_id); + let asset = run(&mut s, "assets.add", json!({"ids":[id]}))["assets"][0].as_u64().unwrap(); + run(&mut s, "variable.define", json!({"name":"Shown","kind":"visibility"})); + run(&mut s, "variable.bind", json!({"variable":"Shown","ids":[id]})); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + run(&mut s, "object.expand", json!({})); + let d = &s.doc().unwrap().doc; + assert!(d.node(NodeId(id)).is_some_and(|n| n.extras().dimension.is_some()), "still a dimension"); + let listed = d.assets.iter().find(|a| a.id == asset).map(|a| a.nodes.clone()); + assert_eq!(listed, Some(vec![NodeId(id)]), "in its asset"); + assert!(d.variables.bindings.get(&NodeId(id)).is_some_and(|b| b.contains("Shown")), "bound to its variable"); +} + +/// A selected group with a geometry effect of its own bakes that effect into everything it holds +/// when its appearance expands, the dimension's lines and label included: the dimension becomes a +/// plain group of that art, as one selected without its art does. +#[test] +fn a_group_effect_expanded_reaches_the_dimension_it_holds() { + for command in ["effect.expandAppearance", "object.expand"] { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + let id = dim(&mut s, art_id); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + let group = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[group]})); + run(&mut s, "effect.apply", json!({"effect":"distort.puckerBloat"})); + run(&mut s, command, json!({})); + let d = &s.doc().unwrap().doc; + let n = d.node(NodeId(id)).unwrap_or_else(|| panic!("{command}: the dimension's group is still there")); + assert!(n.extras().dimension.is_none(), "{command}: a plain group"); + assert_ne!(art(&s, id), live, "{command}: its art is baked with the group's effect"); + } +} + +/// When the dimension selected with its art is all there is to change, the command fails and +/// changes nothing. +#[test] +fn a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[art_id]})); + run(&mut s, "paint.setStroke", json!({"none": true})); + // A plain path: Expand would make a live rectangle a path. + run(&mut s, "object.expand", json!({})); + let id = dim(&mut s, art_id); + for grouped in [false, true] { + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + if grouped { + run(&mut s, "object.group", json!({})); + } + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!(info["appearance"], json!(false), "grouped: {grouped}: Object › Expand… is not dimmed for the dimension's label"); + assert_eq!(info["stroke"], json!(false), "grouped: {grouped}: {info}"); + assert!(!vectorcraft_engine::cmd::expand::has_appearance(&s), "grouped: {grouped}: the menus read this"); + for (command, p) in [ + ("object.expand", json!({})), + ("object.expand", json!({"object":false,"fill":false})), + ("object.path.outlineStroke", json!({})), + ("effect.expandAppearance", json!({})), + ] { + assert!(s.execute(command, &p).is_err(), "grouped: {grouped}: {command} {p}"); + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "grouped: {grouped}: {command} {p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "grouped: {grouped}: {command} {p}"); + } + } +} + +/// The dimension `id` is a dimension that measures nothing, still showing `label`, and a move +/// takes it along without redrawing it from other art. +fn stays_unlinked(s: &mut Session, id: u64, label: &str, context: &str) { + let live = art(s, id); + assert!(live.2, "{context}: still a dimension"); + assert!(recipe(s, id).source.is_none(), "{context}: no longer linked"); + assert_eq!(text(s, id), label, "{context}"); + let at = bounds(s, id); + run(s, "select.set", json!({"ids":[id]})); + run(s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(s, id), live, "{context}"); + assert_eq!(text(s, id), label, "{context}"); + let moved = bounds(s, id); + assert!((moved[0] - at[0] - 30.0).abs() < 1e-6 && (moved[1] - at[1]).abs() < 1e-6, "{context}: {at:?} moved to {moved:?}"); + run(s, "edit.undo", json!({})); +} + +/// Expand replacing only the art a selected dimension measures leaves the dimension where it was, +/// still a dimension, without a link to the art that went; undo links it again. #[test] fn a_dimension_keeps_no_link_to_the_art_expand_replaced() { for circle in [false, true] { @@ -1197,18 +1359,21 @@ fn a_dimension_keeps_no_link_to_the_art_expand_replaced() { } else { dim(&mut s, art_id) }; - let (nodes, labels, _) = art(&s, id); + let live = art(&s, id); + let label = text(&s, id); run(&mut s, "select.set", json!({"ids":[art_id, id]})); run(&mut s, "object.expand", json!({"object":false,"stroke":false})); - assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); - run(&mut s, "select.set", json!({"ids":[id]})); - run(&mut s, "object.move", json!({"dx":30,"dy":0})); - assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); + stays_unlinked(&mut s, id, &label, &format!("circle: {circle}")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "circle: {circle}"); + assert!(recipe(&s, id).source.is_some(), "circle: {circle}: one undo links it again"); } } -/// Outline Stroke and Expand Appearance replacing the art a selected dimension measures leave the -/// dimension as a plain group, whether or not they change its own art. +/// Outline Stroke and Expand Appearance replacing only the art a selected dimension measures +/// leave the dimension where it was, still a dimension, no longer linked; undo links it again. +/// Here the dimension's lines are guides, which neither command changes, and the selection holds +/// the whole dimension or one of its lines. #[test] fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() { for command in ["object.path.outlineStroke", "effect.expandAppearance"] { @@ -1225,19 +1390,44 @@ fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() { }; run(&mut s, "select.set", json!({"ids":lines})); run(&mut s, "view.guides.make", json!({})); + let live = art(&s, id); + let label = text(&s, id); let picked = if whole { id } else { lines[0] }; run(&mut s, "select.set", json!({"ids":[art_id, picked]})); run(&mut s, command, json!({})); assert!(s.doc().unwrap().doc.node(NodeId(id)).is_some(), "{command} removed the dimension, whole: {whole}"); - let plain = art(&s, id); - assert!(!plain.2, "{command}: no recipe left, whole: {whole}"); - run(&mut s, "select.set", json!({"ids":[id]})); - run(&mut s, "object.move", json!({"dx":30,"dy":0})); - assert_eq!(art(&s, id), plain, "{command}, whole: {whole}"); + stays_unlinked(&mut s, id, &label, &format!("{command}, whole: {whole}")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "{command}, whole: {whole}"); + assert!(recipe(&s, id).source.is_some(), "{command}, whole: {whole}: one undo links it again"); } } } +/// Object › Expand that outlines only the stroke of the art a selected dimension measures leaves +/// the dimension a dimension, no longer linked, as Outline Stroke does. +#[test] +fn a_dimension_outlasts_the_stroke_expand_outlined() { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + let id = dim(&mut s, art_id); + let lines: Vec = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect() + }; + run(&mut s, "select.set", json!({"ids":lines})); + run(&mut s, "view.guides.make", json!({})); + let live = art(&s, id); + let label = text(&s, id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + run(&mut s, "object.expand", json!({"object":false,"fill":false})); + stays_unlinked(&mut s, id, &label, "object.expand stroke"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live); + assert!(recipe(&s, id).source.is_some(), "one undo links it again"); +} + /// A part of a dimension expanded on its own ends the link too, so the move does not undo it. #[test] fn expanding_a_part_of_a_dimension_ends_its_link() { @@ -1271,10 +1461,11 @@ fn a_dimension_in_an_expanded_symbol_instance_stays_plain_art() { stays_plain_art(&mut s, root); } -/// Create Outlines on a dimension, or on its label alone, ends the link in the same undo step. +/// Create Outlines on a dimension, also one selected with the art it measures, or on its label +/// alone, ends the link in the same undo step. #[test] fn outlining_a_dimension_or_its_label_ends_its_link() { - for label_alone in [false, true] { + for target in ["dimension", "label", "with its art"] { let mut s = session(); let line = edge(&mut s); let id = dim(&mut s, line); @@ -1283,8 +1474,12 @@ fn outlining_a_dimension_or_its_label_ends_its_link() { let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id }; - let target = if label_alone { label.0 } else { id }; - run(&mut s, "select.set", json!({"ids":[target]})); + let ids = match target { + "label" => vec![label.0], + "with its art" => vec![line, id], + _ => vec![id], + }; + run(&mut s, "select.set", json!({"ids":ids})); run(&mut s, "type.createOutlines", json!({})); stays_plain_art(&mut s, id); run(&mut s, "edit.undo", json!({})); diff --git a/crates/engine/tests/plugin_safe_mode.rs b/crates/engine/tests/plugin_safe_mode.rs index 878ec6bf5..2c5fc1c30 100644 --- a/crates/engine/tests/plugin_safe_mode.rs +++ b/crates/engine/tests/plugin_safe_mode.rs @@ -462,3 +462,43 @@ fn expand_appearance_keeps_a_plug_in_effect_safe_mode_skips() { safe_mode::set_active(Some(safe_mode::Source::Flag)); s.execute("effect.expandAppearance", &json!({})).unwrap(); } + +/// In Safe Mode, a plug-in effect on a dimension selected with the art it measures does not stop +/// Expand Appearance: the dimension is left as it is, its effect included, and the art expands. +#[test] +fn expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art() { + let _f = fresh(); + let mut s = Session::new(); + let fx = wat::parse_str(tpl::returning(&tpl::manifest("test.sm.dim", "effect", "{}"), r#"{"objects":[]}"#)).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&fx)})).unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let art = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 72, "height": 72})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [art], "color": "#000000"})).unwrap(); + s.execute("appearance.addStroke", &json!({})).unwrap(); + let source = json!({"kind": "segment", "edge": {"node": art, "subpath": 0, "segment": 0}}); + let dim = s.execute("dimension.create", &json!({"source": source})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [dim]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "plugin.test.sm.dim"})).unwrap(); + s.execute("select.set", &json!({"ids": [art, dim]})).unwrap(); + let node = |s: &mut Session, id: &Value| { + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + let mut found = Value::Null; + let mut stack = vec![inspect["layers"].clone()]; + while let Some(v) = stack.pop() { + match v { + Value::Array(a) => stack.extend(a), + Value::Object(o) if o.get("id") == Some(id) => found = Value::Object(o), + Value::Object(o) => stack.extend(o.into_iter().map(|(_, v)| v)), + _ => {} + } + } + found + }; + let kept = node(&mut s, &dim); + let measured = node(&mut s, &art); + safe_mode::set_active(Some(safe_mode::Source::Flag)); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + safe_mode::set_active(None); + assert_ne!(node(&mut s, &art), measured, "the art expands"); + assert_eq!(node(&mut s, &dim), kept, "the dimension and its effect stay as they are"); +} diff --git a/docs/dimensions.md b/docs/dimensions.md index bcd08c3b8..6f11a29c9 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -74,14 +74,27 @@ paths and text as ordinary art. Object › Expand, Object › Expand Appearance, and Object › Path › Outline Stroke turn a dimension whose art they change into a plain group of that art, also when they reach only a part of it: moving or scaling the group does not redraw it, it does not follow the art it measured, and undoing the command gives the live dimension back. The menus dim -Object › Expand… while a whole dimension is selected, because its label has appearance items; -Object › Expand Appearance and the `object.expand` command still reach it. A dimension selected with -the art it measures also becomes a plain group when Object › Expand, Object › Expand Appearance or -Object › Path › Outline Stroke changes only that art and the art no longer fits the dimension's link, -for example because its number of anchors changed. When the art keeps its id and anchor count and the -dimension's own art does not change, the dimension stays live and its label shows the new length. Pasted -dimensions keep their geometry and drop their links, so they never bind to unrelated art in another -document. +Object › Expand… while a whole dimension is selected without the art it measures, because its label +has appearance items; Object › Expand Appearance and the `object.expand` command still reach it. + +When a whole dimension is selected together with art it measures, directly or in selected groups +without fills, strokes or effects of their own, Object › Expand, Object › Expand Appearance and +Object › Path › Outline Stroke leave the dimension's lines and label as they are and change only the +other art. The menus do not dim Object › Expand… for the label of such a dimension. If the new art no +longer fits the dimension's link, for example because its number of anchors changed, the dimension +stays where it was, no longer linked, as it does when its art is deleted. Undoing the command links +it again. When the art keeps its id and anchor count, the dimension stays linked and its label shows +the new length. When the dimension is all the command would change, the command changes nothing and +reports the error it gives for a selection with nothing to change: "Expand: nothing to expand", +"Expand Appearance: the selection has a basic appearance" or "Outline Stroke: no stroked paths +selected". A selected group with fills, strokes or effects of its own changes everything it holds +when its appearance expands, its dimensions included: a geometry effect is baked into their lines +and label, which become a plain group, and a blur, feather or inner glow turns them into part of the +image that replaces the group's art. Type › Create Outlines still outlines the label of a dimension +selected with its art, which turns the dimension into a plain group. + +Pasted dimensions keep their geometry and drop their links, so they never bind to unrelated art in +another document. ## Commands diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..21ab8a414 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3602,7 +3602,10 @@ holds symbol instances that `object` expands, whether only their art holds it, w `object` on), and `object.mesh.create` on a gradient-filled object colours the mesh points as the gradient paints them, with the colors Object › Expand gives them. `object.expand`, `effect.expandAppearance`, `type.createOutlines` and `object.path.outlineStroke` turn a dimension whose -art they change into a plain group of that art, in the same undo step (see [dimensions.md](dimensions.md)). +art they change into a plain group of that art, in the same undo step. `object.expand`, `effect.expandAppearance` and +`object.path.outlineStroke` leave a dimension selected with art it measures, directly or in groups without fills, +strokes or effects of their own, as it is and change only the other art; if the new art no longer fits the dimension's +link, the dimension stays where it was, no longer linked (see [dimensions.md](dimensions.md)). `object.expand` rejects a param of the wrong type, such as `"fill": "yes"`, `"gradient": 3` or `"steps": "16"`, and changes nothing. Omitting a param or giving null keeps its default. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdcda0f6e..70eb7877c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -566,6 +566,7 @@ since 2026-10-01. **Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape. **Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it. **Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it. +**Drawing tools, Dimension tool dimensions selected with their art keep their lines and label:** Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave a dimension selected with art it measures, directly or in selected groups without fills, strokes or effects of their own, as it is and change only the other art; when the new art no longer fits its link, the dimension stays where it was, no longer linked, and undo links it again. `expand_leaves_a_dimension_selected_with_its_art_as_it_is`, `expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is`, `outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is`, `a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing`, `a_group_effect_expanded_reaches_the_dimension_it_holds`, `expand_keeps_a_dimension_in_its_asset_and_its_variable_binding`, `a_dimension_keeps_no_link_to_the_art_expand_replaced`, `a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced`, `a_dimension_outlasts_the_stroke_expand_outlined` and `expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art` cover it. **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. **Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo. From 3c44a8ad7c98995861c412aae061a3d984bab785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Tue, 10 Nov 2026 20:36:20 +0000 Subject: [PATCH 21/40] G5: built-in Status Effects graphic style library Eight style libraries; MCP docs list Status Effects and catch up Symbol libraries with UI Frames and Map Markers. --- crates/doc/src/style_libs.rs | 42 +++++++++++++++++++++++++++++ crates/engine/src/tests/stylelib.rs | 12 ++++++++- crates/ui-egui/src/i18n/bg.tsv | 7 +++++ crates/ui-egui/src/i18n/bs.tsv | 7 +++++ crates/ui-egui/src/i18n/ca.tsv | 7 +++++ crates/ui-egui/src/i18n/cs.tsv | 7 +++++ crates/ui-egui/src/i18n/da.tsv | 7 +++++ crates/ui-egui/src/i18n/de.tsv | 7 +++++ crates/ui-egui/src/i18n/el.tsv | 7 +++++ crates/ui-egui/src/i18n/es.tsv | 7 +++++ crates/ui-egui/src/i18n/fi.tsv | 7 +++++ crates/ui-egui/src/i18n/fr.tsv | 7 +++++ crates/ui-egui/src/i18n/hi.tsv | 7 +++++ crates/ui-egui/src/i18n/hu.tsv | 7 +++++ crates/ui-egui/src/i18n/id.tsv | 7 +++++ crates/ui-egui/src/i18n/it.tsv | 7 +++++ crates/ui-egui/src/i18n/ja.tsv | 7 +++++ crates/ui-egui/src/i18n/ka.tsv | 7 +++++ crates/ui-egui/src/i18n/ko.tsv | 7 +++++ crates/ui-egui/src/i18n/nl.tsv | 7 +++++ crates/ui-egui/src/i18n/pl.tsv | 7 +++++ crates/ui-egui/src/i18n/pt-br.tsv | 7 +++++ crates/ui-egui/src/i18n/ru.tsv | 7 +++++ crates/ui-egui/src/i18n/sv.tsv | 7 +++++ crates/ui-egui/src/i18n/tr.tsv | 7 +++++ crates/ui-egui/src/i18n/uk.tsv | 7 +++++ crates/ui-egui/src/i18n/vi.tsv | 7 +++++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 +++++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 +++++ docs/gaps.md | 14 +++++----- docs/mcp.md | 2 +- docs/roadmap.md | 3 ++- docs/target-app-parity.md | 7 ++--- 33 files changed, 257 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 90424d5bc..f64bdb430 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -159,6 +159,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud }, + BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects }, ]; /// Built-in library `id`, made once. @@ -482,6 +483,43 @@ fn game_hud() -> Vec { ] } +/// Status Effects: buffs and debuffs for RPG/gamedev art (original, catalogue stylize effects only). +fn status_effects() -> Vec { + let stealth = GraphicStyle { + opacity: 0.45, + blend: BlendMode::Normal, + ..style("Stealth Fade", vec![F(fill("#7f8c8d")), S(stroke("#2c3e50", 1.0))], vec![]) + }; + vec![ + style( + "Poison Tint", + vec![F(fill("#2ecc71"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 80.0, "blur": 6.0, "color": "#58d68d"}))], + ), + style( + "Freeze Edge", + vec![F(fill("#aed6f1")), S(stroke("#5dade2", 2.0))], + vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 70.0, "blur": 5.0, "color": "#d6eaf8", "source": "edge"}))], + ), + style( + "Burn Aura", + vec![F(fill("#e67e22"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 8.0, "color": "#f39c12"}))], + ), + style( + "Stun Pulse", + vec![F(fill("#f4d03f"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 75.0, "blur": 7.0, "color": "#f7dc6f"}))], + ), + stealth, + style( + "Shield Buff", + vec![F(fill("#85c1e9")), S(round("#2874a6", 2.0))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 70.0, "blur": 5.0, "color": "#5dade2"}))], + ), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -508,6 +546,10 @@ mod tests { assert_eq!(game.name, "Game HUD"); let names: Vec<&str> = game.styles.iter().map(|g| g.name.as_str()).collect(); assert_eq!(names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]); + let status = builtin_style_library("status-effects").unwrap(); + assert_eq!(status.name, "Status Effects"); + let status_names: Vec<&str> = status.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); } #[test] diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 43838ff8e..5124fb450 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -41,8 +41,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let mut s = session(); let list = run(&mut s, "graphicStyle.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); - assert!(libs.len() >= 7 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); + assert!(libs.len() >= 8 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); @@ -60,6 +61,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let game = run(&mut s, "graphicStyle.library", json!({"library": "game-hud"})); let game_names: Vec<&str> = game["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(game_names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]); + let status = run(&mut s, "graphicStyle.library", json!({"library": "status-effects"})); + let status_names: Vec<&str> = status["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]})); assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow"))); @@ -67,6 +71,12 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#3498db").unwrap())); assert_eq!(node.appearance.stroke_paint(), Paint::None); assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.outerGlow"]); + let id2 = rect(&mut s, 60.0, 20.0, 40.0, 30.0); + let r2 = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "status-effects", "name": "Poison Tint", "apply": true, "ids": [id2.0]})); + assert_eq!((names(&r2["added"]), r2["applied"].as_str()), (vec!["Poison Tint"], Some("Poison Tint"))); + let node2 = doc(&s).node(id2).unwrap(); + assert_eq!(node2.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#2ecc71").unwrap())); + assert_eq!(node2.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.outerGlow"]); // By name in any case. assert_eq!(run(&mut s, "graphicStyle.library", json!({"library": "shadows and glows"}))["id"], "shadows-glows"); assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2d4949879..ffe3c0bd9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3071,6 +3071,13 @@ digits Malayalam Малаялам Quest Outline Квест Coin Shine Монета Boss Alert Бос + Status Effects Статус ефекти + Poison Tint Отрова + Freeze Edge Замразяване + Burn Aura Изгаряне + Stun Pulse Зашеметяване + Stealth Fade Скритост + Shield Buff Щит Shape Фигура Shape Builder Създател на фигури Shape Builder Regions Области на създателя на фигури diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 912eec6a6..56075d850 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -776,6 +776,13 @@ Quest Outline Zadatak Coin Shine Novčić Boss Alert Bos + Status Effects Statusni efekti + Poison Tint Otrov + Freeze Edge Zamrzavanje + Burn Aura Izgaranje + Stun Pulse Omamljenost + Stealth Fade Nevidljivost + Shield Buff Štit Outlines and Rules Obrisi i linije Hand-Drawn Ručno crtano Gradient Finishes Gradijentne završnice diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f12bb4a6a..ee00ace34 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3955,6 +3955,13 @@ digits Malayalam Malaiàlam Quest Outline Missió Coin Shine Moneda Boss Alert Cap + Status Effects Efectes d’estat + Poison Tint Verí + Freeze Edge Congelació + Burn Aura Cremada + Stun Pulse Atordiment + Stealth Fade Sigil + Shield Buff Escut Shape Forma Shape Builder Constructor de formes Shape Builder Delete Suprimeix del constructor de formes diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6d7965acd..be4d6c4b0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -783,6 +783,13 @@ Quest Outline Úkol Coin Shine Mince Boss Alert Boss + Status Effects Stavové efekty + Poison Tint Jed + Freeze Edge Zmrazení + Burn Aura Spálení + Stun Pulse Omráčení + Stealth Fade Plížení + Shield Buff Štít Outlines and Rules Obrysy a linky Hand-Drawn Ručně kreslené Gradient Finishes Přechodové povrchy diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 53036e9dd..523d80dd0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -722,6 +722,13 @@ Quest Outline Quest Coin Shine Mønt Boss Alert Boss + Status Effects Statuseffekter + Poison Tint Gift + Freeze Edge Frost + Burn Aura Brænd + Stun Pulse Bedøvelse + Stealth Fade Snige + Shield Buff Skjold Outlines and Rules Konturer og linjer Hand-Drawn Håndtegnet Gradient Finishes Gradientfinish diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e46a1b6ea..a1fe39b99 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3117,6 +3117,13 @@ digits Malayalam Malayalam Quest Outline Quest Coin Shine Münze Boss Alert Boss + Status Effects Statuseffekte + Poison Tint Gift + Freeze Edge Frost + Burn Aura Brand + Stun Pulse Betäubung + Stealth Fade Schleichen + Shield Buff Schild Shape Form Shape Builder Formerstellung Shape Builder Regions Formerstellungsbereiche diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 98015264e..33a13e502 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3624,6 +3624,13 @@ digits Malayalam Μαλαγιάλαμ Quest Outline Αποστολή Coin Shine Νόμισμα Boss Alert Αρχηγός + Status Effects Εφέ κατάστασης + Poison Tint Δηλητήριο + Freeze Edge Πάγωμα + Burn Aura Κάψιμο + Stun Pulse Ζάλη + Stealth Fade Αορασία + Shield Buff Ασπίδα Shape Builder Regions Περιοχές δημιουργίας σχημάτων Shape Effects Εφέ σχημάτων Shape Mode Λειτουργία σχήματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d5d844b23..6153e45ed 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3156,6 +3156,13 @@ digits Malayalam Malayálam Quest Outline Misión Coin Shine Moneda Boss Alert Jefe + Status Effects Efectos de estado + Poison Tint Veneno + Freeze Edge Congelación + Burn Aura Quemadura + Stun Pulse Aturdimiento + Stealth Fade Sigilo + Shield Buff Escudo Shape Forma Shape Builder Generador de formas Shape Builder Regions Regiones del generador de formas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 85b49e77e..f4a0f90d7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -774,6 +774,13 @@ Quest Outline Tehtävä Coin Shine Kolikko Boss Alert Bossi + Status Effects Tilatehosteet + Poison Tint Myrkky + Freeze Edge Jäädytys + Burn Aura Poltto + Stun Pulse Tainnutus + Stealth Fade Hiipiminen + Shield Buff Kilpi Outlines and Rules Ääriviivat ja viivat Hand-Drawn Käsin piirretyt Gradient Finishes Liukuväriviimeistelyt diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 43eb6d757..14bbc40db 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3148,6 +3148,13 @@ digits Malayalam Malayalam Quest Outline Quête Coin Shine Pièce Boss Alert Boss + Status Effects Effets de statut + Poison Tint Poison + Freeze Edge Gel + Burn Aura Brûlure + Stun Pulse Étourdissement + Stealth Fade Furtivité + Shield Buff Bouclier Shape Forme Shape Builder Concepteur de forme Shape Builder Regions Zones du Concepteur de forme diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 796939065..95bfbf3a2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4267,6 +4267,13 @@ antiAlias Sharp शार्प Quest Outline क्वेस्ट Coin Shine सिक्का Boss Alert बॉस + Status Effects स्थिति प्रभाव + Poison Tint ज़हर + Freeze Edge ठंड + Burn Aura जलन + Stun Pulse स्तब्ध + Stealth Fade छिपना + Shield Buff ढाल Shaft: दस्ता: Shape शेप Shape Builder शेप बिल्डर diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ec7d13a8c..14b27cda4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3771,6 +3771,13 @@ mark-style Japanese Japán Quest Outline Küldetés Coin Shine Érme Boss Alert Főellenség + Status Effects Állapot hatások + Poison Tint Méreg + Freeze Edge Fagy + Burn Aura Égés + Stun Pulse Kábítás + Stealth Fade Lopakodás + Shield Buff Pajzs Shape Alakzat Shape Builder Alakzatépítő Shape Builder Regions Alakzatépítő területei diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 45d30eb0f..4328a522e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -722,6 +722,13 @@ Quest Outline Quest Coin Shine Koin Boss Alert Boss + Status Effects Efek Status + Poison Tint Racun + Freeze Edge Beku + Burn Aura Bakar + Stun Pulse Pingsan + Stealth Fade Menghilang + Shield Buff Perisai Outlines and Rules Garis luar dan garis Hand-Drawn Gambar tangan Gradient Finishes Finishing gradien diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 419cf0582..0f383b9a6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3193,6 +3193,13 @@ digits Malayalam Malayalam Quest Outline Missione Coin Shine Moneta Boss Alert Boss + Status Effects Effetti di stato + Poison Tint Veleno + Freeze Edge Gelo + Burn Aura Bruciatura + Stun Pulse Stordimento + Stealth Fade Furtività + Shield Buff Scudo Shape Forma Shape Builder Generatore forme Shape Builder Regions Aree del Generatore forme diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index effd2a900..bbf0cd6e2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -807,6 +807,13 @@ digits Malayalam マラヤーラム数字 Quest Outline クエスト Coin Shine コイン Boss Alert ボス + Status Effects ステータス効果 + Poison Tint 毒 + Freeze Edge 凍結 + Burn Aura 火傷 + Stun Pulse 気絶 + Stealth Fade ステルス + Shield Buff シールド Outlines and Rules アウトラインと罫線 Hand-Drawn 手描き風 Gradient Finishes グラデーション仕上げ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e1b2fceef..f58d58c20 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -670,6 +670,13 @@ Quest Outline ქვესტი Coin Shine მონეტა Boss Alert ბოსი + Status Effects სტატუსის ეფექტები + Poison Tint შხამი + Freeze Edge გაყინვა + Burn Aura დაწვა + Stun Pulse დაბნეულობა + Stealth Fade შენიღბვა + Shield Buff ფარი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული Gradient Finishes გრადიენტული ზედაპირები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2fa677459..e94dd0cef 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -800,6 +800,13 @@ digits Malayalam 말라얄람어 Quest Outline 퀘스트 Coin Shine 코인 Boss Alert 보스 + Status Effects 상태 효과 + Poison Tint 독 + Freeze Edge 빙결 + Burn Aura 화상 + Stun Pulse 기절 + Stealth Fade 은신 + Shield Buff 방패 Outlines and Rules 윤곽선과 괘선 Hand-Drawn 손그림 Gradient Finishes 그레이디언트 마감 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 10c923cd6..7cc1b7801 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -736,6 +736,13 @@ Quest Outline Quest Coin Shine Munt Boss Alert Baas + Status Effects Statuseffecten + Poison Tint Gif + Freeze Edge Bevriezing + Burn Aura Brand + Stun Pulse Verdoving + Stealth Fade Sluipen + Shield Buff Schild Outlines and Rules Omtrekken en lijnen Hand-Drawn Handgetekend Gradient Finishes Verloopafwerkingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a4f8580ff..f2e9e3bfe 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -730,6 +730,13 @@ Quest Outline Zadanie Coin Shine Moneta Boss Alert Boss + Status Effects Efekty statusu + Poison Tint Trucizna + Freeze Edge Mróz + Burn Aura Oparzenie + Stun Pulse Ogłuszenie + Stealth Fade Ukrycie + Shield Buff Tarcza Outlines and Rules Kontury i linie Hand-Drawn Rysowane odręcznie Gradient Finishes Wykończenia gradientowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2496b775e..5c5ed5314 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2648,6 +2648,13 @@ digits Malayalam Malaiala Quest Outline Missão Coin Shine Moeda Boss Alert Chefe + Status Effects Efeitos de status + Poison Tint Veneno + Freeze Edge Congelamento + Burn Aura Queimadura + Stun Pulse Atordoamento + Stealth Fade Furtividade + Shield Buff Escudo Shape Forma Shape Builder Construtor de Formas Shape Builder Regions Regiões do Construtor de Formas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5f34c30ec..d97ce00b2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3188,6 +3188,13 @@ digits Malayalam Малаялам Quest Outline Квест Coin Shine Монета Boss Alert Босс + Status Effects Эффекты статуса + Poison Tint Яд + Freeze Edge Заморозка + Burn Aura Ожог + Stun Pulse Оглушение + Stealth Fade Скрытность + Shield Buff Щит Shape Фигура Shape Builder Создание фигур Shape Builder Regions Области создания фигур diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index aab245107..bf3fa9cf6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3080,6 +3080,13 @@ digits Malayalam Malayalam Quest Outline Uppdrag Coin Shine Mynt Boss Alert Boss + Status Effects Statuseffekter + Poison Tint Gift + Freeze Edge Frost + Burn Aura Bränna + Stun Pulse Bedövning + Stealth Fade Smyga + Shield Buff Sköld Shape Form Shape Builder Formbyggare Shape Builder Regions Formbyggarområden diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 12e5a14e3..28b08c8b5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2984,6 +2984,13 @@ digits Malayalam Malayalam Quest Outline Görev Coin Shine Jeton Boss Alert Boss + Status Effects Durum Etkileri + Poison Tint Zehir + Freeze Edge Donma + Burn Aura Yanma + Stun Pulse Sersemletme + Stealth Fade Gizlilik + Shield Buff Kalkan Shape Şekil Shape Builder Şekil Oluşturucu Shape Builder Regions Şekil Oluşturucu Bölgeleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a7854dc66..8c958e5b4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3185,6 +3185,13 @@ digits Malayalam Малаялам Quest Outline Квест Coin Shine Монета Boss Alert Бос + Status Effects Ефекти статусу + Poison Tint Отрута + Freeze Edge Заморозка + Burn Aura Опік + Stun Pulse Оглушення + Stealth Fade Невидимість + Shield Buff Щит Shape Фігура Shape Builder Створення фігур Shape Builder Regions Області створення фігур diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ce683ff03..558dab9a9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3517,6 +3517,13 @@ mark-style Japanese Kiểu Nhật Quest Outline Nhiệm vụ Coin Shine Xu Boss Alert Trùm + Status Effects Hiệu ứng trạng thái + Poison Tint Độc + Freeze Edge Đóng băng + Burn Aura Bỏng + Stun Pulse Choáng + Stealth Fade Ẩn thân + Shield Buff Khiên Shape Hình dạng Shape Builder Tạo hình dạng Shape Builder Regions Vùng Tạo 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 91d0e4e74..4e5debe0d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3023,6 +3023,13 @@ digits Malayalam 马拉雅拉姆数字 Quest Outline 任务 Coin Shine 金币 Boss Alert 首领 + Status Effects 状态效果 + Poison Tint 中毒 + Freeze Edge 冰冻 + Burn Aura 灼烧 + Stun Pulse 眩晕 + Stealth Fade 隐身 + Shield Buff 护盾 Shape 形状 Shape Builder 形状生成器 Shape Builder Regions 形状生成器区域 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 399377101..c85912229 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4134,6 +4134,13 @@ digits Malayalam 馬來亞拉姆數字 Quest Outline 任務 Coin Shine 金幣 Boss Alert 首領 + Status Effects 狀態效果 + Poison Tint 中毒 + Freeze Edge 冰凍 + Burn Aura 灼燒 + Stun Pulse 暈眩 + Stealth Fade 隱身 + Shield Buff 護盾 Shape 形狀 Shape Builder 形狀建立 Shape Builder Regions 形狀建立區域 diff --git a/docs/gaps.md b/docs/gaps.md index f0619ff21..92ba84be3 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 (G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously 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 @@ -89,7 +89,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) | 9–19 | no | [target-app-parity.md](target-app-parity.md) | +| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 7–17 | 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) | @@ -648,14 +648,15 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Direct Selection of a path, compound path or type inside a dynamic instance also takes opacity and blend (`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`, Eyedropper), for that instance only; - graphic style libraries (Window › Graphic Style Libraries; seven original libraries generated in code, including - Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert; User Defined; Other + graphic style libraries (Window › Graphic Style Libraries; eight original libraries generated in code, including + Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects + with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the seven shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers and Inventory Icons; 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:** 7–17 h. +- **Estimate:** 6–16 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-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | | 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. | | 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: in Feature submission evidence, a stray table row and two "PR #NNNN revision" notes move into Feature revisions, the three "Feature evidence last updated" lines become one, the duplicate Flyout update note goes (its row stays), and eight reconciliation rows become two. Scores and estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..1238fe8d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2967,7 +2967,7 @@ as a value left out. ## Graphic style libraries Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and -Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD). `graphicStyle.libraries` lists them +Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). `graphicStyle.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? | names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the diff --git a/docs/roadmap.md b/docs/roadmap.md index 02e0a61fc..82362eb94 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 (M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -189,6 +189,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | | 2026-11-10 | minor | M10/G5: Inventory Icons built-in symbol library (Sword, Shield, Potion, Key, Gem); estimates unchanged | | 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | M10/G5: Game HUD built-in graphic style library; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdcda0f6e..1a9f630bb 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-11 · **Change:** minor (G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -538,7 +538,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 | ~40% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have four (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons), graphic style libraries seven (including Game HUD) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~42% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have four (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) 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 | @@ -1315,7 +1315,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. - - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; seven generated style libraries (including Game HUD) save and load as `.vcstyles`. + - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; eight generated style libraries (including Game HUD and Status Effects) save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Load text styles from another document:** the Character Styles and Paragraph Styles panel menus have Load Character Styles… or Load Paragraph Styles… and Load All Styles…, which copy the styles of a document picked in an open dialog, with the swatches and patterns they paint with, in one undo step (`charStyle.load`, `paraStyle.load`, `textStyle.loadAll`); a style the document already has, under its name or a numbered one, is not added again, any other whose name is taken gets a number, and the file's Normal styles are not loaded. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). @@ -1963,6 +1963,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Type advanced: the Edit Custom Dictionary dialog, #4936; evidence added; the Check Spelling dialog remains; scores and hours unchanged. | | 2026-11-10 | minor | File formats: in a `.ai` or EPS opened from its editing data, an envelope of type opens live where it draws as its drawn art and the layers with it draw as the page (the envelope weighed as the art it draws); elsewhere, and where a text or visibility variable binds its type, it keeps its drawn art, with a note (#3179); evidence updated; scores and hours unchanged. | From bcdb908b47e8d10830f9f939d53f556579c81e5c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:38:30 -0800 Subject: [PATCH 22/40] Docs: the Control bar's Paragraph alignment buttons are pinned at 1536 pt #6090 moved the pinned window width in the_control_bar_shows_the_paragraph_alignment_of_the_selected_type from 1440 to 1536 pt, since the bar's right end now keeps room for the panel menu button. The progress line still named 1440 pt. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdcda0f6e..13611caf8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1949,7 +1949,7 @@ Edit Custom Dictionary followed (2026-11-10), as the same page's "Edit the custo ## Control bar: Paragraph section while type is selected (2026-11-06) -With type selected, the Control bar shows a Paragraph: link and the Align left, Align center and Align right buttons after its Character controls, as the public documentation has the Control panel format paragraphs while the Type tool is in use, with a way to the Paragraph panel ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html)), and its alignment buttons align text as the Paragraph panel's do ([Align text](https://helpx.adobe.com/illustrator/using/align-text.html)). A button aligns the selected type (`text.setStyle {justify}`, one undo step) and the shared alignment shows selected; the link opens the Paragraph panel in a popover under it, as Character: opens the Character panel. Keeping to the three alignments that don't justify is our choice, for the bar's room; the popover has all seven. Short of room the bar leaves controls out from its right end: the inline X/Y/W/H fields, the Distribute and Align buttons, area type's vertical alignment, then these buttons and last the link, keeping room for the Transform link; on a 1440 pt bar with one point type object, the buttons show (pinned by a headless test, #5372). +With type selected, the Control bar shows a Paragraph: link and the Align left, Align center and Align right buttons after its Character controls, as the public documentation has the Control panel format paragraphs while the Type tool is in use, with a way to the Paragraph panel ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html)), and its alignment buttons align text as the Paragraph panel's do ([Align text](https://helpx.adobe.com/illustrator/using/align-text.html)). A button aligns the selected type (`text.setStyle {justify}`, one undo step) and the shared alignment shows selected; the link opens the Paragraph panel in a popover under it, as Character: opens the Character panel. Keeping to the three alignments that don't justify is our choice, for the bar's room; the popover has all seven. Short of room the bar leaves controls out from its right end: the inline X/Y/W/H fields, the Distribute and Align buttons, area type's vertical alignment, then these buttons and last the link, keeping room for the Transform link; on a 1536 pt bar with one point type object, the buttons show (pinned by a headless test, #5372). ## Area type: vertical alignment in the Properties and Control panels (2026-11-04) From 0f13edaabedf3013734367cfcaf849e3b239b853 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:40:36 -0800 Subject: [PATCH 23/40] Graphs: centered labels with kept leading stay centered Category labels beside an axis and labels in pie wedges are centered on their place by the default leading. A multi-line label that keeps more leading or paragraph spacing ran down from that place instead. The graph now puts the middle of such a label's first and last baselines on its place. --- crates/engine/src/cmd/graph/labels.rs | 38 ++++++++++++++++++++------- crates/engine/src/cmd/graph/tests.rs | 26 ++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index faf301899..9d521c5d9 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -48,17 +48,21 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) -> /// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the /// alignment. pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle { - let kept = match role { - Role::Value => &styles.values_para, - Role::Category => &styles.categories_para, - Role::Legend => &styles.legend_para, - }; - let mut para = kept.clone().unwrap_or_default(); + let mut para = kept_para(styles, role).cloned().unwrap_or_default(); // A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label). para.keep_spacing_in_range(); para } +/// The paragraph attributes labels of `role` keep, if any. +fn kept_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::ParaStyle> { + match role { + Role::Value => styles.values_para.as_ref(), + Role::Category => styles.categories_para.as_ref(), + Role::Legend => styles.legend_para.as_ref(), + } +} + /// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp. pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias { let kept = match role { @@ -85,6 +89,11 @@ impl Gen<'_> { self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5) } pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { + let t = self.text_object(role, at, s, justify); + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + } + /// Label `s` of `role` set at `at` and laid out, not yet in the document. + fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject { let style = label_style(self.styles, role); let mut t = TextObject::point(at, s, style); t.para = label_para(self.styles, role); @@ -92,14 +101,25 @@ impl Gen<'_> { t.para.justify = justify; t.system_layout = self.d.setup.system_layout; refresh_bounds(&mut t); - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + t } /// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred /// on `at` (labels beside an axis or in a wedge) instead of running down from it. pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc { let lines = s.split('|').count(); - let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at }; - self.text(role, at, &s.replace('|', "\n"), justify) + // Default leading puts the first and last baselines this far above and below `at`. + let half = (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0; + let start = if middle { Point::new(at.x, at.y - half) } else { at }; + let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify); + // Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An + // empty first or last line has no baseline, and such a label stays where the default leading puts it. + if middle && kept_para(self.styles, role).is_some() { + let ys: Vec = t.baselines().map(|(a, _)| a.y).collect(); + if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ys.len() == lines) { + t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; + } + } + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) } pub(super) fn group(&mut self, name: &str, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c5316e765..7f85763a7 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4344,6 +4344,32 @@ fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() { assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}"); } +#[test] +fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_again() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ|1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let middle = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n1")).unwrap(); + let b = label.geometric_bounds().unwrap(); + (b.y0 + b.y1) / 2.0 + }; + let before = middle(&s); + let l = text_id(&s, id, "Axes", "Q\n1"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400, "spaceBefore": 6})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let after = middle(&s); + assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}"); +} + #[test] fn a_legend_across_top_measures_its_labels_with_system_layout() { let mut s = Session::new(); From 32e22cb442e8d50712e384905692feea698f1a4d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:44:11 -0800 Subject: [PATCH 24/40] Effects: pasted art keeps an SVG filter whose name the document gives another filter Pasted art whose svg.filter effect named one of the document's SVG filters but drew another definition kept that name, so editing or deleting the document's filter rewrote the pasted art's filter. Paste now renames such a filter to a free name ("Soft 2") when the art comes from another document or from SVG, and takes the document's filter when the art is pasted back into the document it was copied from. Symbols, patterns, brushes and graphic styles on the clipboard follow the same rule. --- crates/engine/src/cmd/clipboard/resources.rs | 86 +++++++++++++++++++- crates/engine/src/tests/svgfilter.rs | 52 ++++++++++++ docs/mcp.md | 4 +- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 4488b4746..5bea00948 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -21,8 +21,8 @@ use serde_json::{Map, Value, json}; use vectorcraft_brush::Brush; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, - TextStyleDef, + Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, + SymbolRegistration, TextStyleDef, }; use super::super::brushsym::{set_natural_size, stored_natural_size}; @@ -140,6 +140,31 @@ fn node_refs(n: &Node, f: Visit) { }); } +/// The `(name, xml)` of `e` when it is an SVG filter effect. +fn filter_of(e: &Effect) -> Option<(String, String)> { + let text = |k: &str| e.params.get(k).and_then(Value::as_str).map(str::to_string); + if e.id != Appearance::SVG_FILTER { + return None; + } + Some((text("name")?, text("xml")?)) +} + +/// The SVG filter effects of appearance `a` (its own and its fills' and strokes'). +fn appearance_filters(a: &Appearance, out: &mut BTreeSet<(String, String)>) { + let items = a.items.iter().flat_map(|i| i.effects().iter()); + out.extend(a.effects.iter().chain(items).filter_map(filter_of)); +} + +/// The SVG filter effects in the subtree of `n` (opacity-mask art included). +fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) { + n.walk(&mut |m| { + appearance_filters(&m.appearance, out); + if let Some(mask) = &m.extras().mask { + node_filters(&mask.art, out); + } + }); +} + /// A global or spot colour of its own (not a tint of another swatch): what colours link to. fn is_base(w: &Swatch) -> bool { (w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. }) @@ -527,6 +552,9 @@ impl SwatchChoices { #[derive(Clone, Debug, Default)] struct Relink { renames: BTreeMap>, + /// An SVG filter effect's `(name, xml)` on the clipboard → its `(name, xml)` in the document + /// ([`Clipboard::filter_links`]). + filters: BTreeMap<(String, String), (String, String)>, /// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped. styles: BTreeMap, /// Swatch (its name in the document) → the colour linked colours show at 100 %. @@ -566,6 +594,15 @@ impl Relink { }); } fn appearance(&self, a: &mut Appearance) { + if !self.filters.is_empty() { + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items) { + if let Some((name, xml)) = filter_of(e).and_then(|k| self.filters.get(&k)) { + e.params["name"] = json!(name); + e.params["xml"] = json!(xml); + } + } + } for it in &mut a.items { self.paint(it.paint_mut()); if let AppearanceItem::Stroke(l) = it @@ -1013,6 +1050,7 @@ impl Clipboard { /// of definitions that use each other is matched on its own ([`loop_groups`]). fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { let mut out = Imported::default(); + out.relink.filters = self.filter_links(d, same_doc); self.import_images(d, used, same_doc, &mut out); self.import_swatches(d, used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art @@ -1170,10 +1208,52 @@ impl Clipboard { } vectorcraft_brush::store(d, &lib); } - out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty()); + out.relinks = !(out.relink.renames.is_empty() + && out.relink.filters.is_empty() + && out.relink.styles.is_empty() + && out.relink.colors.is_empty() + && used.styles.is_empty()); out } + /// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's + /// filter of its name; one that draws another definition than that filter takes the document's + /// filter when pasted back into the document it came from, and elsewhere keeps its own under a + /// free name, so editing or deleting the document's filter leaves it alone. + fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> { + let mut found = BTreeSet::new(); + for n in &self.nodes { + node_filters(n, &mut found); + } + for s in &self.symbols { + node_filters(&s.art, &mut found); + } + for a in self.patterns.iter().flat_map(|p| p.art.iter()) { + node_filters(a, &mut found); + } + for a in self.brushes.iter().flat_map(Brush::art) { + node_filters(a, &mut found); + } + for g in &self.graphic_styles { + appearance_filters(&g.appearance, &mut found); + } + let mut taken: BTreeSet = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect(); + let mut links = BTreeMap::new(); + for (name, xml) in found { + let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue }; + let to = if same_doc { + (name.clone(), held.xml.clone()) + } else { + let new = unique_name(&name, |n| taken.contains(n)); + taken.insert(new.clone()); + let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new).unwrap_or_else(|| xml.clone()); + (new, drawn) + }; + links.insert((name, xml), to); + } + links + } + /// Image blobs: a different blob under a key the document uses gets a new key. fn import_images(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) { for (key, blob) in self.images.iter().filter(|(k, _)| used.has(Res::Image, k)) { diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index fcb4581c2..4edcc6fd1 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -207,6 +207,58 @@ fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() { assert_eq!(drawn(&s, a), json!(SHIFT_MORE)); } +/// The `(name, xml)` of every `svg.filter` effect on the document's objects. +fn filter_effects(s: &Session) -> Vec<(String, String)> { + let mut out = vec![]; + s.doc().unwrap().doc.walk(|n| { + for e in n.appearance.effects.iter().filter(|e| e.id == vectorcraft_render::effects::SVG_FILTER) { + out.push((e.params["name"].as_str().unwrap().to_string(), e.params["xml"].as_str().unwrap().to_string())); + } + }); + out +} + +/// Pasted art whose filter has a name the document gives another filter keeps its own filter +/// under a free name, so editing or deleting the document's filter leaves it alone. Pasted back +/// into the document it came from, it takes the document's filter as it is now. +#[test] +fn pasted_art_keeps_a_filter_another_of_its_name_would_replace() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "Shift 2", "renamed: {pasted:?}"); + assert!(pasted[0].1.contains("id=\"Shift 2\"") && pasted[0].1.contains("dx=\"9\""), "its own filter: {pasted:?}"); + assert_eq!(names(&mut s), ["Shift"]); + run(&mut s, "svgFilter.delete", json!({"name": "Shift"})); + assert_eq!(filter_effects(&s), pasted, "the document's filter leaves it alone"); + // The same filter as the document's keeps its name. + let mut t = session(); + run(&mut t, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT}"# + ); + run(&mut t, "clipboard.importSvg", json!({"svg": svg})); + run(&mut t, "edit.pasteInPlace", json!({})); + assert_eq!(filter_effects(&t), [("Shift".to_string(), SHIFT.to_string())]); + // Copied, then the filter edited: pasted back, the copy draws the edited filter. + let mut u = session(); + let a = rect(&mut u, 10.0); + run(&mut u, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut u, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut u, "select.set", json!({"ids": [a.0]})); + run(&mut u, "edit.copy", json!({})); + run(&mut u, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + run(&mut u, "edit.pasteInPlace", json!({})); + let both = filter_effects(&u); + assert_eq!(both, [("Shift".to_string(), SHIFT_MORE.to_string()), ("Shift".to_string(), SHIFT_MORE.to_string())]); +} + /// Art in a symbol's definition that uses a filter follows its edits too. #[test] fn a_symbols_art_follows_a_filter_edit() { diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..63ce0bb03 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3680,7 +3680,9 @@ step. `svgFilter.apply {name, ids?|id?, item?}` applies one as the live effect ` in one undo step. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes what every object that uses it draws, in the edit's one undo step, and an application after an edit takes the edited filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml` -empty) until a filter of that name is defined or imported again. Opened from such an effect, Apply SVG Filter shows the +empty) until a filter of that name is defined or imported again. A pasted effect that names one of the document's +filters but draws another definition keeps its own filter under a free name (`Soft 2`); pasted back into the document +it was copied from, it takes the document's filter. Opened from such an effect, Apply SVG Filter shows the name above the list, not selectable. `effect.apply {effect: "svg.filter", params: {xml}}` takes a filter directly and names it by its `id`; an `xml` that isn't one such filter is an error. `ui.svgFilterDialog {}` opens Apply SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edits which filter an applied effect runs; From 67462cf04926e1c1bd31c402d84667943c5863b6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:52:06 -0800 Subject: [PATCH 25/40] Glyphs: gid null inside a ligature reports the ligature When text.setGlyphAlternate leaves the text unchanged, it now reads the glyph from the unchanged text. A character after the first in a run that holds a glyph choice could be reported with its own glyph instead of the ligature drawn for it, which text.glyphAlternates reports. --- crates/engine/src/cmd/textedit.rs | 10 ++++++++++ crates/engine/src/tests/textedit.rs | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 095cad361..79dcee713 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1321,6 +1321,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { } } if held == choice && (choice.is_none() || alone) { + // The text is left as it is, so the glyph is the one the unchanged text draws. The probe + // splits a run that holds an earlier character's choice, and the split can break a ligature + // that takes in this character. + let drawn = match &choice { + None if probe.runs != t.runs => { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + drawn_glyph(&plain, &lay, ch, &bytes, &face).or_else(|| shaped_alone(&t, ch, &bytes, &face).1).unwrap_or(default) + } + _ => drawn, + }; return Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn})); } s.edit("Glyphs", |d, _| { diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 359373911..444794619 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -3511,6 +3511,31 @@ fn a_glyph_choice_taken_away_reports_the_glyph_drawn() { assert_eq!(chosen(&mut s, caps, 1), json!(drawn)); } +/// A run that holds a choice for its first character and goes on past it, as a file may have, +/// draws Source Serif 4's fi ligature for the f and the i after that character. `gid: null` for the +/// i leaves the text as it is and reports the ligature, as `text.glyphAlternates` does. +#[test] +fn a_glyph_choice_taken_away_inside_a_ligature_reports_the_ligature() { + let db = vectorcraft_text::FontDb::global(); + let face = db.face("Source Serif 4", "Regular").expect("the bundled face"); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xfin", "size": 40, "font": "Source Serif 4"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let mut runs = obj(&s, id).runs; + runs[0].style.glyph_alternate = Some(face.choice('x', face.glyph_for('x'))); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(text)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("text") }; + text.runs = runs.clone(); + let ligature = vectorcraft_text::layout(db, &obj(&s, id)).glyphs.iter().find(|g| g.byte == 1 && g.len == 2).expect("f + i is one glyph").gid; + let chosen = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap()["chosen"].clone(); + assert_eq!(chosen, json!(ligature)); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": null})).unwrap(); + assert_eq!(r["gid"], chosen, "the ligature drawn for the i"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no edit"); + assert_eq!(obj(&s, id).runs, runs, "the runs are kept"); +} + /// A fallback face draws Ka with the vowel sign I in one cluster, the vowel sign first. The glyph /// reported for Ka is the fallback face's glyph for Ka, laid out or overflowing, not the vowel /// sign's. From 08eca05731f82e557e2e76fbf0542ad88a6baf68 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:04:44 -0800 Subject: [PATCH 26/40] Scripts: #strict on runs the script with strict write checks instead of refusing it `#strict on` and `//@strict on` no longer end the run as unsupported. They turn on the checks the dollar-object help page gives for `$.strict` (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/): an assignment to a read-only property throws a TypeError, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only; the objects that take no new properties are the non-extensible ones (preventExtensions, seal, freeze, and the frozen vc and $ objects); the last `#strict` line of the script and its includes sets the whole run, and runs without one are unchanged; eval and Function take the checks of the code that compiles them; a file $.evalFile runs keeps its own line's checks for its code and functions, or takes the caller's without one. The rest of ES5 strict mode stays with 'use strict'. The js crate carries the setting per compiled source (Realm::set_strict_writes, and a strict_writes callback on HostItem::Evaluator), and applies it only to property assignments. --- crates/engine/src/cmd/scripts.rs | 2 +- crates/engine/src/script/include.rs | 35 ++++-- crates/engine/src/script/mod.rs | 12 +- crates/engine/src/tests/script_include.rs | 131 +++++++++++++++++++++- crates/js/src/builtins.rs | 2 +- crates/js/src/bytecode.rs | 3 + crates/js/src/heap.rs | 2 +- crates/js/src/host.rs | 24 +++- crates/js/src/interp.rs | 41 ++++++- crates/js/src/interp_tests.rs | 57 +++++++++- crates/js/src/realm.rs | 8 ++ docs/mcp.md | 17 ++- docs/target-app-parity.md | 3 +- 13 files changed, 299 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 66f3f8bf8..af08dc061 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "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 script's #script line, else 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 and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict off is taken; #strict on, #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; 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 a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 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, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error)}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{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 script's #script line, else 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 and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; 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 a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 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, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error)}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index df7702d77..70bfeac56 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -1,8 +1,11 @@ //! A script's preprocessor lines, read before it runs, as the public guide lists them //! (): //! `#include` and `//@include` put the text of another file in place of the line, `#includepath` -//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off` -//! are accepted. The same lines are read in a file `$.evalFile` runs. +//! sets the folders `#include` looks in, `#script` names the run, `#strict on` turns on the checks +//! of `$.strict` (: +//! a write to a read-only property is a runtime error, and objects that take no new properties +//! refuse them), and `#target` and `#strict off` are accepted. The same lines are read in a file +//! `$.evalFile` runs. //! //! VectorCraft's own rules: //! @@ -13,9 +16,15 @@ //! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list. //! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a //! `JSINCLUDE` folder outside the scope, which is passed over. -//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are -//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a -//! comment. +//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as +//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment. +//! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks +//! for the whole run; without one they are off. A write to a property that has only a getter is +//! checked as a read-only one, the objects that take no new properties are those that are not +//! extensible (`Object.preventExtensions`, `Object.seal`, `Object.freeze`), and the rest of ES5 +//! strict mode is left to `'use strict'`. Code that `eval` and `Function` compile takes the +//! checks of the code that compiles it; a file `$.evalFile` runs takes its own `#strict` line if +//! it has one, and otherwise the caller's. //! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files, //! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up //! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has @@ -97,12 +106,13 @@ pub(crate) struct Failure { pub column: u32, } -/// A source with its preprocessor lines read: the text to run, where its lines came from, and the -/// name its `#script` line gave. +/// A source with its preprocessor lines read: the text to run, where its lines came from, the +/// name its `#script` line gave, and what its last `#strict` line set (`None` without one). pub(crate) struct Expanded { pub source: String, pub map: SourceMap, pub name: Option, + pub strict: Option, } /// A file being run: its name for people and the folder its relative paths start from (none for a @@ -130,13 +140,14 @@ pub(crate) struct Files { } /// The output being built: its UTF-16 text, the line it is on, the units since that line began, -/// and whether its last unit is a carriage return. +/// whether its last unit is a carriage return, and what the last `#strict` line so far set. struct Out { units: Vec, line: u32, column: u32, after_cr: bool, map: SourceMap, + strict: Option, } impl Out { @@ -330,7 +341,7 @@ impl Files { /// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`, /// its `#script` line names the run. fn expand(&mut self, shown: Arc, dir: Option, text: &str, script: bool) -> Result { - let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() }; + let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None }; let mut name = None; self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?; let source = String::from_utf16(&out.units).map_err(|_| Failure { @@ -341,7 +352,7 @@ impl Files { line: 0, column: 0, })?; - Ok(Expanded { source, map: out.map, name }) + Ok(Expanded { source, map: out.map, name, strict: out.strict }) } /// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place @@ -414,8 +425,8 @@ impl Files { } } Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() { - "off" => {} - "on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))), + "off" => out.strict = Some(false), + "on" => out.strict = Some(true), _ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))), }, Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))), diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index f2e4cc5ba..fbaa353dc 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -489,6 +489,8 @@ struct Host { /// The last error `$.evalFile` raised for a file it could not run, with the file, line and /// column that error is at. eval_failure: RefCell>, + /// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one). + eval_strict: Cell>, } /// An error `$.evalFile` raised, as thrown, and the file, line and column it is at. @@ -947,7 +949,12 @@ fn dollar_object(host: &Rc) -> Vec<(String, HostItem)> { let eval_file = { let h = host.clone(); let end = host.clone(); - HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) } + let strict = host.clone(); + HostItem::Evaluator { + source: function(move |a| eval_file(&h, a)), + end: Rc::new(move || end.files.borrow_mut().end_eval()), + strict_writes: Rc::new(move || strict.eval_strict.get()), + } }; vec![ ("write".into(), writer(false)), @@ -996,6 +1003,7 @@ fn eval_file(host: &Host, args: Vec) -> std::result::Result Err(preprocessor_error(f)), Ok(expanded) => { *host.script_map.borrow_mut() = expanded.map; + realm.set_strict_writes(expanded.strict == Some(true)); realm .define_host_object("vc", vc_object(&host, args)) .and_then(|()| realm.define_host_object("$", dollar_object(&host))) diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index 2d857b230..b8bb50743 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -194,7 +194,6 @@ fn script_target_other_app_does_not_route() { ("#target\nvc.log(1);", "#target needs a value"), ("#targetengine \"session\"", "unsupported script directive: #targetengine"), ("#engine \"main\"", "unsupported script directive: #engine"), - ("#strict on", "unsupported strict checking: #strict on"), ("#strict maybe", "#strict takes on or off"), ("#frobnicate 1", "unknown script directive: #frobnicate"), ("#include \"x.jsxinc", "unterminated quote in #include"), @@ -223,12 +222,134 @@ fn script_name_comes_from_the_script_line() { let _ = std::fs::remove_dir_all(base); } +/// The script's `ro`, with a read-only `x`. +const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n"; + #[test] -fn script_strict_on_is_refused() { +fn strict_on_runs_and_read_only_write_throws() { let mut s = Session::new(); - let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string(); - assert!(e.contains("unsupported strict checking: #strict on"), "{e}"); - assert_eq!(s.script_report().unwrap()["kind"], "syntax"); + for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] { + let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);"); + let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string(); + assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}"); + let report = s.script_report().unwrap(); + assert_eq!(report["kind"], "thrown", "{head}"); + assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write"); + let line = head.lines().count() as u64 + 4; + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}"); + } + // The error can be caught, and every kind of assignment is checked. + let source = format!( + "#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);" + ); + let r = s.execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1])); + // Without the line, or with `#strict off` last, the write is ignored as before. + for head in ["", "#strict off\n", "#strict on\n#strict off\n"] { + let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap(); + assert_eq!(r["log"], json!([1]), "{head}"); + } +} + +#[test] +fn strict_on_rejects_getter_only_and_nonextensible_additions() { + let mut s = Session::new(); + let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\ + var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\ + t(function () { g.v = 2; });\n\ + t(function () { Object.preventExtensions({}).n = 1; });\n\ + t(function () { Object.seal({a: 1}).n = 1; });\n\ + t(function () { Object.freeze({a: 1}).a = 2; });\n\ + t(function () { vc.extra = 1; });\n\ + var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\ + vc.log(out);\nvc.log(open.n);\nvc.log(set.got);"; + let r = s.execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!( + r["log"], + json!([ + [ + "TypeError: cannot assign to property 'v': it has only a getter", + "TypeError: cannot assign to property 'n': the object is not extensible", + "TypeError: cannot assign to property 'n': the object is not extensible", + "TypeError: cannot assign to property 'a': it is read-only", + "TypeError: cannot assign to property 'extra': the object is not extensible", + "wrote", + "wrote", + "wrote" + ], + 1, + 3 + ]) + ); + let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap(); + assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused"); +} + +#[test] +fn strict_on_does_not_enable_es5_early_errors() { + let mut s = Session::new(); + let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\ + vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());"; + let r = s.execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6])); + // 'use strict' still makes the code strict mode code. + let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string(); + assert!(e.contains("ReferenceError"), "{e}"); + let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string(); + assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}"); +} + +#[test] +fn strict_on_in_include_controls_expanded_run() { + let base = folder("strict"); + write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;"); + write(&base.join("main/off.jsxinc"), "//@strict off\n"); + write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n")); + // An included file's line sets the checks for the script's own code too. + let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;")); + assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}"); + assert_eq!(report["line"], 4, "{report}"); + assert!(report.get("file").is_none(), "{report}"); + // A write in an included file is reported at its own line. + let report = failed(&base, "#strict on\n#include \"writes.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}"); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}"); + // The last line of the expanded run wins, wherever it is. + let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap(); + assert_eq!(r["log"], json!([1])); + let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;")); + assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn strict_mode_in_eval_file_is_scoped() { + let base = folder("strict-eval"); + write( + &base.join("main/on.jsx"), + &format!( + "#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n" + ), + ); + write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';")); + write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';")); + // A file's own `#strict on` holds for its code, its functions included, and not the caller's. + let r = run( + &base, + "$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);", + ) + .unwrap(); + assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]])); + // A file without the line takes the caller's checks, and its own `#strict off` turns them off. + let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap(); + assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks"); + let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');"); + assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}"); + assert_eq!(report["line"], 3, "{report}"); + assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"])); + let _ = std::fs::remove_dir_all(base); } #[test] diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 62f6ade7a..5738beb94 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -472,7 +472,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe Ok(p) => p, Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)), }; - let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag }); + let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag, strict_writes: interp.writes_mode() }); let code = match compile_constructed_function(&program, &source) { Ok(Some(code)) => code, Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")), diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 0a666d1f2..b8bd2783b 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -173,6 +173,9 @@ pub(crate) struct Source { pub(crate) text: Rc<[u16]>, /// Which source this is, as [`crate::Realm::error_source`] gives it. pub(crate) tag: u32, + /// Writes to read-only and getter-only properties, and new properties on objects that are + /// not extensible, throw in this source's code even where it is not strict mode code. + pub(crate) strict_writes: bool, } #[derive(Debug)] diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index b229ce6e6..f7e3b32c5 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -184,7 +184,7 @@ pub(crate) enum Func { Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, Data { f: DataFn, name: Rc, text: bool }, - Eval { f: DataFn, end: Rc, name: Rc }, + Eval { f: DataFn, end: Rc, strict_writes: Rc Option>, name: Rc }, Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index b725245ce..ab4334dc3 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -81,10 +81,12 @@ pub enum HostItem { /// the call gives that code's completion value, and its declarations become globals. A result /// that is not a string is the call's result. `end` is called when the source has run, /// normally or not. Each source given has the next tag of [`crate::Realm::error_source`], - /// from 1. + /// from 1. `strict_writes`, called after `source`, gives [`crate::Realm::set_strict_writes`] + /// for that source alone; with `None` it takes the calling code's. Evaluator { source: HostFunction, end: Rc, + strict_writes: Rc Option>, }, } @@ -302,17 +304,26 @@ impl Interp { /// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the /// source it gives as global code, as an indirect `eval` does, giving that code's completion /// value. A result that is not a string is given to the script as it is. `end` is called once - /// the source has run, normally or not. - pub(crate) fn call_evaluator(&mut self, f: &crate::heap::DataFn, end: &Rc, name: &Rc, args: &[Value]) -> JsResult { + /// the source has run, normally or not. `strict_writes` gives the source's + /// [`crate::bytecode::Source::strict_writes`], or `None` for the calling code's. + pub(crate) fn call_evaluator( + &mut self, + f: &crate::heap::DataFn, + end: &Rc, + strict_writes: &Rc Option>, + name: &Rc, + args: &[Value], + ) -> JsResult { let data = self.read_args(name, args, false)?; let source = match f(data) { Ok(Data::String(source)) => source, Ok(d) => return self.write_data(d, 0, false), Err(e) => return Err(self.host_error(e)?), }; + let writes = strict_writes().unwrap_or_else(|| self.writes_mode()); self.evaluated = self.evaluated.saturating_add(1); let tag = std::mem::replace(&mut self.source_tag, self.evaluated); - let code = self.encode_source(&source).and_then(|units| self.compile_source(&units, false, crate::bytecode::CodeKind::Eval)); + let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval)); self.source_tag = tag; let r = match code { Ok(code) => self.run_global(code, true), @@ -411,9 +422,10 @@ impl Interp { HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, true)), flags: ENUMERABLE }, HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, false)), set: None, flags: ENUMERABLE }, HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, - HostItem::Evaluator { source, end } => { + HostItem::Evaluator { source, end, strict_writes } => { let proto = self.intrinsics.function_proto; - let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto))); + let func = Func::Eval { f: source, end, strict_writes, name: full.into() }; + let f = self.heap.alloc_object(Object::new(Class::Function(func), Some(proto))); Prop::Data { value: Value::Object(f), flags: ENUMERABLE } } }; diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 22bbb6c0d..87db42646 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -191,6 +191,8 @@ pub(crate) struct Interp { pub(crate) evaluated: u32, /// The tag of the source the last run's error was raised in. pub(crate) error_tag: u32, + /// [`crate::bytecode::Source::strict_writes`] for the scripts the realm runs. + pub(crate) strict_writes: bool, } /// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's @@ -385,6 +387,7 @@ impl Interp { source_tag: crate::realm::UNKNOWN_SOURCE, evaluated: 0, error_tag: crate::realm::UNKNOWN_SOURCE, + strict_writes: false, }; crate::builtins::install(&mut interp); interp.fuel = limits.fuel; @@ -691,6 +694,18 @@ impl Interp { self.frames.last().is_some_and(|f| f.code.strict) } + /// Does a property write the running code makes throw when it cannot be made? It does in + /// strict mode code and in code from a source with [`crate::bytecode::Source::strict_writes`]. + fn strict_writes(&self) -> bool { + self.frames.last().is_some_and(|f| f.code.strict || f.code.source.strict_writes) + } + + /// [`crate::bytecode::Source::strict_writes`] for source compiled now: the running code's, + /// or the realm's between runs. + pub(crate) fn writes_mode(&self) -> bool { + self.frames.last().map_or(self.strict_writes, |f| f.code.source.strict_writes) + } + fn exec(&mut self, op: Op) -> JsResult<()> { match op { Op::Undefined => self.push(Value::Undefined), @@ -909,11 +924,14 @@ impl Interp { _ => Ok(()), } } - Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict), + Resolved::Obj { object, .. } => { + let throw = self.strict_writes(); + self.put(object, name.clone(), v, throw) + } Resolved::Unresolved if strict => Err(self.not_defined(name)), Resolved::Unresolved => { - let global = self.global; - self.put(global, name.clone(), v, false) + let (global, throw) = (self.global, self.strict_writes()); + self.put(global, name.clone(), v, throw) } } } @@ -1444,7 +1462,10 @@ impl Interp { fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> { let strict = self.strict(); match base { - Value::Object(o) => self.put(*o, k, v, strict), + Value::Object(o) => { + let throw = self.strict_writes(); + self.put(*o, k, v, throw) + } Value::Undefined | Value::Null => { let message = format!( "cannot set property '{}' of {}", @@ -1860,7 +1881,9 @@ impl Interp { // keeps itself reachable. return Ok(Prepared::Done(self.call_data(&f, &name, &args, text)?)); } - Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)), + Func::Eval { f, end, strict_writes, name } => { + return Ok(Prepared::Done(self.call_evaluator(&f, &end, &strict_writes, &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) { @@ -2129,11 +2152,17 @@ impl Interp { /// Parses and compiles source for eval code or a script, counting it against the source /// limit. pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result, CompileError> { + let writes = self.writes_mode(); + self.compile_source_writes(units, strict, writes, kind) + } + + /// [`Interp::compile_source`], with [`crate::bytecode::Source::strict_writes`] set to `writes`. + pub(crate) fn compile_source_writes(&mut self, units: &[u16], strict: bool, writes: bool, kind: CodeKind) -> Result, CompileError> { self.source_units = self.source_units.saturating_add(units.len()); let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len())); let options = Options { strict, max_source_units: remaining, ..self.options.clone() }; let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?; - let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag }); + let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag, strict_writes: writes }); compile_program(&program, &source, kind).map_err(|e| match e { CompileFail::Syntax(s) => CompileError::Syntax(s), CompileFail::Internal(m) => CompileError::Internal(m), diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index c7f69ec9b..b6fec2637 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -691,6 +691,61 @@ fn a_strict_realm_runs_scripts_as_strict_mode_code() { assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into()))); } +#[test] +fn strict_writes_are_separate_from_es5_strict() { + let setup = "var ro = {}; Object.defineProperty(ro, 'x', {value: 1}); \ + var getter = {get g() { return 1; }}; var shut = Object.preventExtensions({a: 1}); var open = {};"; + let mut realm = Realm::new(); + realm.set_strict_writes(true); + realm.run(setup).unwrap(); + for (src, why) in [ + ("ro.x = 2", "cannot assign to property 'x': it is read-only"), + ("ro['x'] += 1", "cannot assign to property 'x': it is read-only"), + ("ro.x++", "cannot assign to property 'x': it is read-only"), + ("getter.g = 2", "cannot assign to property 'g': it has only a getter"), + ("shut.b = 2", "cannot assign to property 'b': the object is not extensible"), + ("Object.create(ro).x = 2", "cannot assign to property 'x': it is read-only"), + ("NaN = 1", "cannot assign to property 'NaN': it is read-only"), + ("(function () { ro.x = 2; })()", "cannot assign to property 'x': it is read-only"), + ("eval('ro.x = 2')", "cannot assign to property 'x': it is read-only"), + ("(0, eval)('ro.x = 2')", "cannot assign to property 'x': it is read-only"), + ("Function('ro.x = 2')()", "cannot assign to property 'x': it is read-only"), + ] { + let e = realm.run(src).unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Thrown, "{src}"); + assert!(e.message.contains("TypeError") && e.message.contains(why), "{src}: {}", e.message); + } + // Writes that can be made, and the rest of strict mode, are as before. + assert_eq!(realm.run("open.n = 1; shut.a = 5; open.n + shut.a"), Ok(JsValue::Number(6.0))); + assert_eq!(realm.run("with ({w: 2}) { w } "), Ok(JsValue::Number(2.0))); + assert_eq!(realm.run("(function (a, a) { return a; })(1, 2)"), Ok(JsValue::Number(2.0))); + assert_eq!(realm.run("010"), Ok(JsValue::Number(8.0))); + assert_eq!(realm.run("undeclared = 3; undeclared"), Ok(JsValue::Number(3.0))); + assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into()))); + assert_eq!(realm.run("delete Object.prototype"), Ok(JsValue::Bool(false))); + assert_eq!(realm.run("'abc'.length = 1; 'abc'.extra = 1; 1"), Ok(JsValue::Number(1.0)), "a primitive's properties as in sloppy mode"); + // 'use strict' keeps all of ES5 strict mode. + assert_eq!(realm.run("'use strict'; with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax)); + assert_eq!(realm.run("'use strict'; delete Object.prototype").map_err(|e| e.kind), Err(RunErrorKind::Thrown)); + // Off, the same writes are ignored, as in sloppy mode code. + realm.set_strict_writes(false); + assert_eq!(realm.run("ro.x = 2; getter.g = 2; shut.b = 2; NaN = 1; ro.x + getter.g + String(shut.b)"), Ok(JsValue::String("2undefined".into()))); + // A function compiled with the writes on keeps them when called from code without. + let mut realm = Realm::new(); + realm.set_strict_writes(true); + realm.run(&format!("{setup} function w() {{ ro.x = 2; }}")).unwrap(); + realm.set_strict_writes(false); + assert!(realm.run("w()").unwrap_err().message.contains("it is read-only")); + assert_eq!(realm.run("ro.x = 3; ro.x"), Ok(JsValue::Number(1.0))); + // An undeclared name added to a global object that is not extensible. + let mut realm = Realm::new(); + realm.set_strict_writes(true); + let e = realm.run("Object.preventExtensions(this); newName = 1").unwrap_err(); + assert!(e.message.contains("cannot assign to property 'newName': the object is not extensible"), "{}", e.message); + let mut realm = Realm::new(); + assert_eq!(realm.run("Object.preventExtensions(this); newName = 1; typeof newName"), Ok(JsValue::String("undefined".into()))); +} + #[test] fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() { // Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay @@ -774,7 +829,7 @@ fn deep_nesting_compiles_on_a_small_stack() { // ---- the verifier ---- fn code(ops: Vec) -> Code { - let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::::new()), tag: 0 }); + let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::::new()), tag: 0, strict_writes: false }); Code { positions: vec![0; ops.len()], ops, diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index beaad2557..92452a609 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -174,6 +174,14 @@ impl Realm { self.unbroken_fuel = fuel; } + /// With `on`, the scripts run next (and the code they define and evaluate) throw a + /// `TypeError` when they assign to a read-only or getter-only property or add a property to + /// an object that is not extensible, as strict mode code does, without the rest of strict + /// mode. Off by default. + pub fn set_strict_writes(&mut self, on: bool) { + self.interp.strict_writes = on; + } + pub fn limits(&self) -> &Limits { &self.interp.limits } diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..d3c056981 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1587,9 +1587,20 @@ VectorCraft reads them by these rules of its own: folder of the file that includes. A later `#includepath` replaces the list. - `#script "name"` names the run in the report and the Script Console, unless `name` is given. A `#script` line in an included file does not rename the run. -- `#target` takes any value and does not change how the script runs. `#strict off` is taken. -- `#strict on`, `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` - with an unknown-directive error. A `//@` line with another name is a comment. +- `#target` takes any value and does not change how the script runs. +- `#strict on` turns on the checks the dollar-object help page gives for `$.strict`: an assignment to a read-only + property throws a `TypeError`, and so does adding a property to an object that takes no new ones. By VectorCraft's + own rules, an assignment to a property with only a getter is checked as a read-only one, and the objects that take + no new properties are those that are not extensible: objects passed to `Object.preventExtensions`, `Object.seal` or + `Object.freeze`, and the frozen `vc` and `$` objects. Other objects take new properties as before. The rest of ES5 + strict mode (`with`, undeclared variables, `this` in a plain call) is unchanged; a script asks for it with + `'use strict'`. `#strict off` turns the checks off, and they are off in a script without either line. +- The last `#strict` line of the script and the files it includes sets the checks for the whole run. Code that `eval` + and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of + its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the + caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`. +- `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` with an + unknown-directive error. A `//@` line with another name is a comment. - A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 49713d661..06b63c5a5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -729,7 +729,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.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). From 16cc5c107c6bbfecb2c42c7394f691c86d89d7b9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:09:08 -0800 Subject: [PATCH 27/40] Docs: a row without paint leaves the series' stored paint, not its opacity The graph still stores an opacity or blend mode set on a series mark's row that has no paint, as it does for transparency.set. The sentence now says that such a row leaves only the stored paint unchanged. --- docs/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..f8c344981 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -588,7 +588,7 @@ the stored fill, or the stored stroke when no fill is stored and that stroke has outline, so these commands do not store a stroke paint or weight for a series that draws a band. Except for `paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. Graph designs keep their own paint. A row that had no -paint before the command and has none after it does not change what the graph stores. `paint.setFill`, +paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and From 3a44447aeb29f2da9d4726454c97701a3f521053 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:17:45 -0800 Subject: [PATCH 28/40] Docs: remove the duplicated G9 .ai sentence that says .ai editing data is not written A merge left a second copy of the G9 `.ai`/EPS Missing bullet's opening inside the bullet. That copy says writing the editing data is out of scope, while the same bullet and file-format-parity.md say a PDF-compatible `.ai` carries an editing copy of the art. The bullet now has one copy, with the newer brush wording that matches the 2026-11-05 entry above it. --- docs/gaps.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index f0619ff21..2edc2e575 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -211,19 +211,14 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead - of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a - path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form + of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art + brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and - of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art - brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope - (undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As - `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification - instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and images, and type as editable text objects (or outlines), one artboard per file (an artboard picker chooses which); transparency, live effects and object names don't survive it (transparent images are flattened or omitted, hidden objects go to a hidden layer, are omitted or made visible), and type is set again in the From c81ae88c85b270c4d2e808db900c88141c9ec924 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:23:59 -0800 Subject: [PATCH 29/40] Flatten Transparency: a long rasterized area is placed whole The flattener limited an image's resolution only by its total pixels, while the renderer draws at most 32,768 pixels a side. A long area, such as a narrow see-through gradient strip down a 2 m banner at 1200 ppi, came back cut off at that length and was placed as if whole, so the rest of the strip was lost in Flatten Transparency and in PDF/X-1a, PDF/X-3 and PDF 1.3 exports. The resolution is now also limited by the longest side, as raster effects are. --- crates/engine/src/cmd/flatten/apply.rs | 3 +++ crates/engine/src/cmd/flatten/commands.rs | 2 +- crates/engine/src/tests/flatten.rs | 25 +++++++++++++++++++++++ docs/mcp.md | 4 +++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 555058e58..409db3f1b 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -484,6 +484,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { let mut scale = ppi / 72.0; scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); + // No side longer than the renderer draws: the image grows by a pixel on each side and is + // rounded up to whole pixels below. + scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)); let px = 1.0 / scale; let rect = rect.inflate(px, px); let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index 53687713b..4053dcac7 100644 --- a/crates/engine/src/cmd/flatten/commands.rs +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -10,7 +10,7 @@ pub fn specs() -> Vec { "Flatten Transparency…", ["Object"], None, - "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach; an image larger than 64 Mpx or 32,768 px a side is made at a lower resolution), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_doc, flatten ), diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index 2e08b99c8..84e4724fe 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -350,6 +350,31 @@ fn gradients_rasterize_and_flat_areas_stay_vector() { } } +#[test] +fn a_long_rasterized_area_is_placed_whole() { + let _settings = settings_lock(); + let mut s = session(); + // 5569 pt long: more than the renderer's 32,768 pixels a side at the 64 Mpx limit's scale. + let g = rect(&mut s, 10.0, 10.0, 100.0, 5569.0, "#000000"); + run( + &mut s, + "paint.setFill", + json!({"ids": [g], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), + ); + run(&mut s, "transparency.set", json!({"ids": [g], "opacity": 40})); + let (ids, _, rasterized) = flatten(&mut s, json!({"ids": [g], "lineArtPpi": 1200, "gradientPpi": 1200})); + assert_eq!(rasterized, 1); + let mut placed = vec![]; + node(&s, ids[0]).walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + placed.push((im.height, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))))); + } + }); + let [(height, b)] = placed[..] else { panic!("one image: {placed:?}") }; + assert!(height <= vectorcraft_render::MAX_RASTER_SIDE, "{height} pixels"); + assert!(b.y0 <= 10.0 && b.y1 >= 5579.0, "the image covers the strip: {b:?}"); +} + #[test] fn flatten_says_when_an_image_could_not_be_decoded() { let _settings = settings_lock(); diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..8a91a5fdc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3207,7 +3207,9 @@ the group holds only the art inside the clipping layer, drawn over the fill and layer as its own layers draw it (over nothing with `preserveAlpha`), and the art outside is flattened on its own. `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) -override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups +override it, at the top level or in `options`. An image that would be larger than 64 million pixels or longer than +32,768 pixels a side at `lineArtPpi` or `gradientPpi` is made at the highest resolution that fits, so a long area is +rasterized whole. `balance: 0` rasterizes everything; lower balances rasterize groups that split into many regions. The result reports `{ids, vector, rasterized, options}`. When an image in the art can't be decoded, the image that replaces the art does not show it, and the result also carries `warning`: "an image couldn't be decoded and is missing from the picture", the note raster exports give. From 8b2ff77429630b37ee92c7ba800fa20cfe79f449 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:27:30 -0800 Subject: [PATCH 30/40] Plug-ins: a plug-in may call effect.deferredStatus but not effect.refreshDeferred effect.deferredStatus only lists the document's deferred stages, so it joins reviewed::ALLOWED. effect.refreshDeferred runs plug-in effects again, so it joins reviewed::DENIED, which fixes every_command_is_reviewed. --- crates/engine/src/cmd/plugin_call/reviewed.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 7759a57f0..f7c7003b1 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -152,6 +152,7 @@ pub(super) const ALLOWED: &[&str] = &[ "edit.findNext", "edit.findReplace", "effect.apply", + "effect.deferredStatus", "effect.duplicate", "effect.expandAppearance", "effect.list", @@ -662,11 +663,13 @@ pub(super) const ALLOWED: &[&str] = &[ ]; /// Commands a plug-in may not call although no rule refuses them, in order: they change the -/// preferences, a tool's options, a drag's state or the font names the whole app knows. +/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run +/// plug-ins again. pub(super) const DENIED: &[&str] = &[ "addon.cleanup.splitText", "addon.pathtools.movePointsTogether", "addon.pen.nextSegment", + "effect.refreshDeferred", "graphicStyle.setOptions", "object.blend.options", "object.liquify", From 2b378689d17f4d688dc936a243e6482e4ca0cc0b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:38:07 -0800 Subject: [PATCH 31/40] Tests: the command sweep sends file and folder params only into its temp folder The tool-edit sweep sent `{"": "on"}` for every documented key, so `prefs.export` wrote a preferences file named `on` into crates/engine on every run, and `folder`, `destinationFolder` and `diffPath` strings from `junk_params` named locations under the working directory. - `is_path_key` covers `path`, `diffPath`, `folder` and `destinationFolder`; `safe_value` sends a temp file for a file key and a temp folder for a folder key, and both sweeps use it. - A new test builds both sweeps' cases for every command and fails when a string under one of those keys is outside the temp folder, without running any command. --- crates/engine/tests/command_sweep.rs | 67 +++++++++++++++++++++------- crates/testkit/src/strategies.rs | 26 ++++++++--- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index de14c38b5..947cf559d 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -9,10 +9,11 @@ use vectorcraft_engine::{Session, command_specs}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::Fixture; use vectorcraft_testkit::invariants::{check_all, check_session, doc_json}; -use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys}; +use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value}; /// Commands that replace/close the active document or touch the filesystem are fine to call, but a -/// few need their paths redirected into a temp dir (handled by `junk_params`). +/// few need their file and folder params redirected into a temp dir (`safe_value`, which +/// `junk_params` applies). fn safe_path() -> String { vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string() } @@ -230,6 +231,54 @@ fn tool_edit_state(s: &Session) -> ToolEditState { (docs, s.active_index(), s.paint.clone()) } +/// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and +/// off, with a string under a file or folder key replaced by a location inside `path`'s folder. +fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { + let mut cases = junk_params(c.params, path); + if heavy(c.id) { + cases.truncate(12); + } + for k in param_keys(c.params) { + for v in [json!(true), json!(false), json!(1), json!("on")] { + cases.push(json!({ k.as_str(): safe_value(&k, &v, path) })); + } + } + // Find Objects selects only with a filter, which one key at a time does not give it. + if c.id == "document.find" { + cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]); + } + cases +} + +/// Every string the sweeps send under a key that names a file or folder to write is inside the +/// sweep's temp folder, so no run writes into the working directory. +#[test] +fn sweep_cases_name_files_only_inside_the_temp_folder() { + let path = safe_path(); + let dir = vectorcraft_testkit::temp_dir("sweep"); + let keys = ["path", "diffPath", "folder", "destinationFolder"]; + let mut seen = std::collections::BTreeSet::new(); + let mut outside = std::collections::BTreeSet::new(); + for c in command_specs() { + let mut cases = junk_params(c.params, &path); + cases.extend(tool_edit_cases(c, &path)); + for p in &cases { + let Some(o) = p.as_object() else { continue }; + for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) { + seen.insert(k.clone()); + if let Some(s) = v.as_str() + && !std::path::Path::new(s).starts_with(&dir) + { + outside.insert(format!("{} {{\"{k}\": {v}}}", c.id)); + } + } + } + } + assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}"); + let outside: Vec = outside.into_iter().collect(); + assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n")); +} + /// Every command that runs at once while the active tool computes an edit on another thread (a /// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the /// documents' revisions and selections, the active document and the current paint as they were, @@ -241,19 +290,7 @@ fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() { let mut failures = vec![]; for fx in [Fixture::Single, Fixture::Multi] { for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) { - let mut cases = junk_params(c.params, &path); - if heavy(c.id) { - cases.truncate(12); - } - for k in param_keys(c.params) { - for v in [json!(true), json!(false), json!(1), json!("on")] { - cases.push(json!({ k.as_str(): v })); - } - } - // Find Objects selects only with a filter, which one key at a time does not give it. - if c.id == "document.find" { - cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]); - } + let cases = tool_edit_cases(c, &path); for p in &cases { let mut s = fx.session(); let before = tool_edit_state(&s); diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 4dbac8f6e..10b060800 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -502,10 +502,26 @@ pub fn param_keys(doc: &str) -> Vec { out } -/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could -/// write files anywhere, so callers substitute a temp path. +/// Parameter-name keys that name a file or folder a command may write: fuzzing these with +/// arbitrary strings could write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - k == "path" + matches!(k, "path" | "diffPath") || is_folder_key(k) +} + +/// Path keys that name a folder a command creates or writes files into. +pub fn is_folder_key(k: &str) -> bool { + matches!(k, "folder" | "destinationFolder") +} + +/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for +/// a folder key. +pub fn safe_location(k: &str, safe_path: &str) -> String { + if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() } +} + +/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string. +pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value { + if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() } } /// A deterministic list of junk param objects for `doc` (the command's params doc): each @@ -516,7 +532,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec { let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})]; for k in &keys { for (i, v) in junk.iter().enumerate() { - let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() }; + let v = safe_value(k, v, safe_path); // Sparse: one key at a time with every junk value, plus a few combos. let mut o = serde_json::Map::new(); o.insert(k.clone(), v.clone()); @@ -524,7 +540,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec { if i % 5 == 0 { let mut all = serde_json::Map::new(); for k2 in &keys { - let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() }; + let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() }; all.insert(k2.clone(), v2); } out.push(Value::Object(all)); From fdedff519e615fc79b061e044b46510e558e25e0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:40:41 -0800 Subject: [PATCH 32/40] Graphs: centered labels with an empty middle line stay centered A centered label such as Q||1 has no baseline for its empty middle line, so a label that keeps more leading was not moved back to its place. The graph now centers a label whose first and last lines hold characters, whatever the lines between them hold. --- crates/engine/src/cmd/graph/labels.rs | 6 ++++-- crates/engine/src/cmd/graph/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 9d521c5d9..b11571425 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -112,10 +112,12 @@ impl Gen<'_> { let start = if middle { Point::new(at.x, at.y - half) } else { at }; let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify); // Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An - // empty first or last line has no baseline, and such a label stays where the default leading puts it. + // empty line has no baseline: an empty first or last line leaves the label where the default leading puts it. if middle && kept_para(self.styles, role).is_some() { let ys: Vec = t.baselines().map(|(a, _)| a.y).collect(); - if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ys.len() == lines) { + let ends_set = s.split('|').next().is_some_and(|l| !l.is_empty()) && s.split('|').next_back().is_some_and(|l| !l.is_empty()); + let set = s.split('|').filter(|l| !l.is_empty()).count(); + if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ends_set && ys.len() == set) { t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; } } diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 7f85763a7..efcd04320 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4370,6 +4370,33 @@ fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_aga assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}"); } +#[test] +fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ||1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let middle = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = + group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n\n1")).unwrap(); + let b = label.geometric_bounds().unwrap(); + (b.y0 + b.y1) / 2.0 + }; + let before = middle(&s); + let l = text_id(&s, id, "Axes", "Q\n\n1"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let after = middle(&s); + assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}"); +} + #[test] fn a_legend_across_top_measures_its_labels_with_system_layout() { let mut s = Session::new(); From ee40cc10d7dd2574dd22ea3086cb0d9190e35500 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:55:59 -0800 Subject: [PATCH 33/40] Rasterize: art longer than 32,768 pixels at the resolution is refused, not cut off Object > Rasterize and the redrawn path of Crop Image allowed images up to 65,535 pixels a side, but the renderer draws at most 32,768. A 100 x 5569 pt rectangle rasterized at 600 ppi came back 32,768 pixels tall, and the bottom 29% of the art was gone. Such an image is now refused like one over the 64 megapixel limit, and the art stays as it was. --- crates/engine/src/cmd/menucmds.rs | 6 ++++-- crates/engine/src/tests/menucmds.rs | 20 ++++++++++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index ed1767779..ba6941eb0 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", has_selection, rasterize ), @@ -670,7 +670,9 @@ fn render_pixels( opts: &vectorcraft_render::RenderOptions, color: RasterColorModel, ) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> { - if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 { + // No side longer than the renderer draws: it would cut the image off there. + let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE); + if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > side || region.height() * scale > side { return Err(EngineError::Other("image would be too large; lower the resolution".into())); } let mut r = vectorcraft_render::Renderer::new(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index cf19f3cb1..7ebe24716 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1461,6 +1461,26 @@ fn rasterize_replaces_selection_with_image() { assert_eq!(selected(&s), vec![img]); } +/// Rasterize refuses art that would be longer than the renderer draws (32,768 pixels a side), and +/// the art stays as it was; at a resolution that fits, the image covers all of it. +#[test] +fn rasterize_refuses_an_image_longer_than_the_renderer_draws() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 100.0, 5569.0); + sel(&mut s, &[a]); + let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap(); + let before = (undo_len(&s), node(&s, a)); + let e = s.execute("object.rasterize", &json!({"ppi": 600, "padding": 0})).unwrap_err().to_string(); + assert!(e.contains("too large"), "{e}"); + assert_eq!((undo_len(&s), node(&s, a)), before); + let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap()); + let n = node(&s, img); + let NodeKind::Image(im) = &n.kind else { panic!() }; + assert!(im.height <= vectorcraft_render::MAX_RASTER_SIDE, "{} pixels", im.height); + let b = n.geometric_bounds().unwrap(); + assert!(close(b.y0, vb.y0) && b.y1 >= vb.y1, "{b:?} covers {vb:?}"); +} + /// Rasterize refuses a resolution given as text or as another value that is not a number, and the /// selected art stays as it was. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..b3eee15f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5169,7 +5169,8 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o When an image in the art can't be decoded, `object.rasterize` and `object.cropImage` (without `trim`) make their image without it, and their results carry the `warning` that Flatten Transparency gives. `object.rasterize` takes each frame of threaded text that it rasterizes out of its thread with the text it shows, as `type.createOutlines` does (see -Threading text from the in and out ports), and the other frames keep the text they hold. +Threading text from the in and out ports), and the other frames keep the text they hold. `object.rasterize` +refuses an image larger than 64 million pixels or longer than 32,768 pixels a side, and the art stays as it was. ```json {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} From 2788707eed2bf1b238032f7578dd04dd7d4161c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:57:45 -0800 Subject: [PATCH 34/40] Docs: the script directives gap lists what remains --- docs/gaps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index f0619ff21..2bc5b47c1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -689,7 +689,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G6 Automation and scripting -- **Missing:** the document object model for scripts and script directives (#1183 is +- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, File › Scripts lists the scripts of the Scripts folder, and Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and From 794efc978b8ca283f66c79f33472863a3d83756d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 03:00:46 -0800 Subject: [PATCH 35/40] Type: an opened file flows its threads in its own composite fonts Threaded type read from a file was flowed through its frames before the document's composite fonts were registered, so a story set in one was measured in the fallback font. Frames then held the wrong share of the story after opening, until a frame was edited. load_with now lends the document's composite fonts while it lays out its threads. --- crates/engine/src/cmd/compositefonts/tests.rs | 26 +++++++++++++++++++ crates/engine/src/cmd/fileio/load.rs | 6 ++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index f51f066df..7997ca8f6 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -478,6 +478,32 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// Threaded type read from a file flows through its frames in the file's own composite fonts, when +/// no open document defines them. +#[test] +fn an_opened_file_flows_its_threads_in_its_own_composite_fonts() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Threaded")]})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(4); + let a = s + .execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 20, "font": "CF Threaded", "area": {"width": 150, "height": 60}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + let first = text(&s, a).plain_text(); + assert!(!first.is_empty() && first.len() < story.len()); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + // A new front document without the font: the font database no longer holds it. + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert!(vectorcraft_text::FontDb::global().composite_font("CF Threaded").is_none()); + let file = crate::cmd::fileio::read_for_open("threaded.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + assert_eq!(text(&s, a).plain_text(), first); +} + /// A document replaced while in the back (File › Revert) is measured in its own composite fonts /// when it comes to the front, not in the front document's. #[test] diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1c1989bb9..9b3223829 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -506,7 +506,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // document keeps its text as saved), and every frame of a thread takes its first frame's // Optical Margin Alignment setting and writing direction. When that changes the direction of a // frame of a restored document, the story flows through that frame's thread again. Threaded - // frames keep their size: Auto Size is off for them. + // frames keep their size: Auto Size is off for them. The story is laid out in the document's + // own composite fonts. + let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() }; + let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts); if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { @@ -517,6 +520,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // paragraph's first-line indent and list marker. crate::cmd::threads::mark_continued(&mut doc); } + drop(lent); if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } From 8ebcc88c2e7f218cb56fbce022176f129e595a18 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 03:03:53 -0800 Subject: [PATCH 36/40] Effects: a pasted SVG filter at the size limit still draws under its new name Pasted art whose SVG filter is renamed to a free name ("Soft 2") had the new name written into the filter's id. For a filter at the 64 KB limit the longer id made it too long to draw, so the pasted art lost the effect. Paste now takes the first free name under which the filter still fits: a shorter start of its name ("Sof"), else a free letter or digit. The filter's id is always the effect's name. --- crates/engine/src/cmd/clipboard/resources.rs | 19 ++++++++++++--- crates/engine/src/tests/svgfilter.rs | 25 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 5bea00948..22703b467 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -1219,7 +1219,9 @@ impl Clipboard { /// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's /// filter of its name; one that draws another definition than that filter takes the document's /// filter when pasted back into the document it came from, and elsewhere keeps its own under a - /// free name, so editing or deleting the document's filter leaves it alone. + /// free name, so editing or deleting the document's filter leaves it alone. When that name would + /// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else + /// a free letter or digit; the filter's id is always its name. fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> { let mut found = BTreeSet::new(); for n in &self.nodes { @@ -1244,9 +1246,20 @@ impl Clipboard { let to = if same_doc { (name.clone(), held.xml.clone()) } else { - let new = unique_name(&name, |n| taken.contains(n)); + // Free names made from the starts of the name, longest first, then free letters and + // digits: the first under which the filter still draws. + let mut ends: Vec = name.char_indices().map(|(i, _)| i).skip(1).collect(); + ends.push(name.len()); + let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty()); + let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n))); + let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n)); + let renamed = shorter.chain(single).find_map(|new| { + let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new) + .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok())?; + Some((new, drawn)) + }); + let Some((new, drawn)) = renamed else { continue }; taken.insert(new.clone()); - let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new).unwrap_or_else(|| xml.clone()); (new, drawn) }; links.insert((name, xml), to); diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index 4edcc6fd1..7606cebe4 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -259,6 +259,31 @@ fn pasted_art_keeps_a_filter_another_of_its_name_would_replace() { assert_eq!(both, [("Shift".to_string(), SHIFT_MORE.to_string()), ("Shift".to_string(), SHIFT_MORE.to_string())]); } +/// A pasted filter at the size limit, which "Shift 2" would make too long to draw, keeps its own +/// definition under a free name no longer than its own (a free letter for a one-letter name), and +/// its id is that name. +#[test] +fn pasted_art_with_a_filter_at_the_size_limit_still_draws_when_renamed() { + for (name, held, renamed) in [("Shift", SHIFT, "Shif"), ("S", r#""#, "a")] { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": held})); + let (open, close) = (format!(r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + assert!(vectorcraft_render::effects::svg_filter::check(&big).is_ok()); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, renamed, "renamed: {}", pasted[0].0); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(renamed.to_string()), "the pasted filter draws under its name"); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); + assert_eq!(names(&mut s), [name]); + } +} + /// Art in a symbol's definition that uses a filter follows its edits too. #[test] fn a_symbols_art_follows_a_filter_edit() { From 68f3095cfd5bf3526b942c029e40c54178e3a188 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 03:21:57 -0800 Subject: [PATCH 37/40] Print: Print as Bitmap prints all of a page longer than 32,768 pixels Print as Bitmap limited each page's image to 65,535 pixels a side, but the renderer draws at most 32,768. A 9000 x 100 pt artboard at a 300 ppi raster effects resolution printed as an image that stopped at 7,864 pt and was placed as if whole. The resolution is now lowered until the longest side fits. --- crates/engine/src/cmd/print.rs | 2 +- crates/engine/src/cmd/printadvanced.rs | 6 ++++-- crates/engine/src/tests/printadvanced.rs | 22 ++++++++++++++++++++++ docs/mcp.md | 4 +++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 44de123fa..76e09641f 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index d181541c3..537658447 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -76,9 +76,11 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b let mut images = vec![]; for (_, r) in regions { let area = outset(r, bleed); - // At most MAX_PIXELS, and 65535 pixels a side. + // At most MAX_PIXELS, and no side longer than the renderer draws (rounded up to whole + // pixels below). let side = area.width().max(area.height()).max(1.0); - let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side); + let longest = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0; + let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(longest / side); if !(scale.is_finite() && scale > 0.0) { continue; } diff --git a/crates/engine/src/tests/printadvanced.rs b/crates/engine/src/tests/printadvanced.rs index 99cfcd373..8faa2f7ad 100644 --- a/crates/engine/src/tests/printadvanced.rs +++ b/crates/engine/src/tests/printadvanced.rs @@ -130,6 +130,28 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() { assert!(crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none()); } +/// Print as Bitmap of an artboard longer than the renderer draws at the raster effects resolution +/// (32,768 pixels a side) prints an image of all of it, at a lower resolution. +#[test] +fn print_as_bitmap_prints_all_of_a_long_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 9000, "height": 100})).unwrap(); + s.execute("document.rasterEffectsSettings", &json!({"resolution": 300})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 9000, "height": 100})).unwrap(); + let set = settings(json!({"advanced": {"printAsBitmap": true}})); + let saved = s.prefs.flattener_presets.clone(); + let bitmap = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap(); + let mut placed = vec![]; + bitmap.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + placed.push((im.width, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))))); + } + }); + let [(width, b)] = placed[..] else { panic!("one image: {placed:?}") }; + assert!(width <= vectorcraft_render::MAX_RASTER_SIDE, "{width} pixels"); + assert!(b.x0 <= 0.0 && b.x1 >= 9000.0, "the image covers the artboard: {b:?}"); +} + /// Appearance of Black › Printing / Exporting (#394): Print as Bitmap draws pure CMYK black as RGB /// black when `rich_black` is on. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..4bd806c42 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6122,7 +6122,9 @@ as Overprint Preview shows them (darkened); either way the file has no overprint overprints. `flattenerPreset` (High Resolution, Medium Resolution, Low Resolution or a saved preset, `flattener.presets.list`) flattens transparency before printing, as Object → Flatten Transparency does; empty, a PDF keeps it live (PostScript, which has none, uses `file.print`'s `flattenerPreset`, else this one, else medium). -`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution. +`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution, +or at the highest resolution that fits when the image would be larger than 64 million pixels or longer than 32,768 +pixels a side. `color.profile` names an RGB or CMYK profile: composite PDF colours are converted to it with `color.intent` (CMYK colours keep their numbers with `preserveNumbers`), so a CMYK profile writes device CMYK; separations separate with a CMYK one. Settings saved before these options load with their defaults. From ea0dd5fda548af4918a5b076daf0973b8bb8d9bc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 03:21:58 -0800 Subject: [PATCH 38/40] Legacy .ai export: a long transparent image is flattened whole Saving a legacy .ai of version 7 flattens each transparent image at its own resolution, limited only by total pixels. The renderer draws at most 32,768 pixels a side, so an image that came out longer than that at its resolution was cut off and placed as if whole: the part past that length showed the art under it without the image. The resolution is now lowered until the longest side fits. --- crates/engine/src/cmd/fileio/legacy_ai.rs | 4 +++- crates/engine/src/tests/legacy_ai.rs | 25 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 9bf7a311a..a197363b2 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -270,7 +270,9 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { let pt = im.xf.determinant().abs().sqrt(); let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI }; let area = (bounds.width() * bounds.height()).max(1.0); - let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()); + // No side longer than the renderer draws (rounded up to whole pixels below). + let longest = (f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0) / bounds.width().max(bounds.height()).max(1.0); + let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()).min(longest); if !(scale.is_finite() && scale > 0.0) { return Ok(None); } diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 9a9aafcba..fefed0ad1 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -368,6 +368,31 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() { assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image"); } +/// A transparent image longer than the renderer draws at its own resolution (32,768 pixels a +/// side) is flattened whole, at a lower resolution. +#[test] +fn a_long_transparent_image_is_flattened_whole() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 4600})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 4600})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&png(2000, 1600, [0, 0, 0, 128])), "at": [50, 50]}); + s.execute("file.place", &p).unwrap(); + // 10 × 4500 pt: about 600 ppi, so 37,500 pixels long. + s.execute("object.setBounds", &json!({"x": 10, "y": 10, "width": 10, "height": 4500, "reference": 0})).unwrap(); + let before = [colour_at(&s.doc().unwrap().doc, 15.0, 100.0), colour_at(&s.doc().unwrap().doc, 15.0, 4400.0)]; + assert!(before.iter().all(|c| c[0] < 200), "the image darkens the red: {before:?}"); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + let after = [colour_at(d, 15.0, 100.0), colour_at(d, 15.0, 4400.0)]; + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {after:?}"); + } +} + /// A `size`×`size` PNG of a black disk of radius `r` pixels with an anti-aliased edge, clear /// around it, 72 ppi. fn disk(size: u32, r: f64) -> Vec { From 8b39cbc324f4a6c818e9ead05aa2ceec1887ee31 Mon Sep 17 00:00:00 2001 From: jmverges Date: Wed, 11 Nov 2026 10:54:30 +0000 Subject: [PATCH 39/40] .ai save: stroked type keeps its cap, join and miter limit The text document writer wrote a type stroke's paint and weight but not its cap, join or miter limit, which .ai import reads since #6500 (keys 61, 62 and 64). A file saved here and opened again came back with butt caps, miter joins and a miter limit of 10, and the save reported those attributes as left out. A stroked run now writes the three keys, numbered as PostScript numbers caps and joins, with the miter limit as import takes it back (1 to 500, else 10), and they no longer count as lost. A run without a stroke no longer counts them as lost either: they draw nothing. Follow-up to #6472. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/ai_editing/tests.rs | 24 +++++++++- crates/eps/src/ai_editing/writer/texts.rs | 56 +++++++++++++++++++++-- docs/file-format-parity.md | 3 +- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 28f67f215..2023b8d47 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3449,6 +3449,9 @@ fn typed() -> Document { v_scale: 90.0, stroke: solid(Color::rgb(0.0, 0.5, 0.0)), stroke_width: 0.5, + stroke_cap: vectorcraft_doc::LineCap::Square, + stroke_join: vectorcraft_doc::LineJoin::Bevel, + stroke_miter_limit: 2.5, all_caps: true, ..regular.clone() }; @@ -3565,7 +3568,7 @@ fn type_lines(doc: &Document) -> Vec { for r in &t.runs { let s = &r.style; out.push(format!( - " run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} caps={} small={:?} tcy={} roman={}", + " run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} {:?} {:?} {} caps={} small={:?} tcy={} roman={}", r.text, s.font_family, s.font_style, @@ -3578,6 +3581,9 @@ fn type_lines(doc: &Document) -> Vec { paint(&s.fill), paint(&s.stroke), r3(s.stroke_width), + s.stroke_cap, + s.stroke_join, + r3(s.stroke_miter_limit), s.all_caps, s.small_caps.map(r3), s.tate_chu_yoko, @@ -3718,6 +3724,22 @@ fn type_comes_back_as_type() { assert_eq!(back.text_threads.len(), 1, "the thread comes back"); } +/// A run without a stroke whose stroke's cap, join and miter limit aren't the defaults loses +/// nothing that draws: the save reports no attributes left out. +#[test] +fn an_unstroked_run_with_a_stroke_style_loses_nothing() { + use vectorcraft_doc::{CharStyle, LineCap, LineJoin, TextObject}; + for miter in [3.0, f64::NAN] { + let st = CharStyle { stroke_cap: LineCap::Round, stroke_join: LineJoin::Bevel, stroke_miter_limit: miter, ..style(14.0, Color::BLACK) }; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Plain", st)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{miter}: {:?}", out.warnings); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1e02941c3..5ebd73de3 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -21,15 +21,15 @@ use std::fmt::Write; use kurbo::{ParamCurveArclen, PathSeg}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, - PathEffect, TextKind, TextObject, VerticalAlign, + AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection, + ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign, }; use vectorcraft_geom::{Affine, PathData, Point}; use crate::ps; /// What the text document holds of type, and what it leaves out. -pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and stroke weight, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; +pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; /// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it. const STREAM_VERSION: u32 = 14; @@ -347,6 +347,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { fill: unlinked(&s.fill), stroke: unlinked(&s.stroke), stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, + stroke_cap: if stroked { s.stroke_cap } else { CharStyle::default().stroke_cap }, + stroke_join: if stroked { s.stroke_join } else { CharStyle::default().stroke_join }, + stroke_miter_limit: if stroked { miter_limit(s.stroke_miter_limit) } else { CharStyle::default().stroke_miter_limit }, all_caps: s.all_caps, small_caps: s.small_caps.filter(|v| *v == default_small_caps), tate_chu_yoko: s.tate_chu_yoko, @@ -357,7 +360,19 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { }; let run_lost = |s: &CharStyle| { let stroked = !s.stroke.is_none(); - let s = CharStyle { stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, ..s.clone() }; + // Without a stroke, its weight, cap, join and miter limit draw nothing: nothing is lost. + let unstroked = CharStyle::default(); + let s = if stroked { + s.clone() + } else { + CharStyle { + stroke_width: unstroked.stroke_width, + stroke_cap: unstroked.stroke_cap, + stroke_join: unstroked.stroke_join, + stroke_miter_limit: unstroked.stroke_miter_limit, + ..s.clone() + } + }; // A Lab color is written as RGB. let lab = |p: &Paint| matches!(p, Paint::Solid { color: Color::Lab { .. }, .. }); s != held_char(&s) || lab(&s.fill) || lab(&s.stroke) || !font_kept(&s) @@ -463,6 +478,32 @@ fn font_name(st: &CharStyle) -> FontRecord { } } +/// The text document's number for a stroke's cap (key 61), as PostScript numbers them: 0 butt, 1 +/// round, 2 projecting. +fn cap_code(cap: LineCap) -> i64 { + match cap { + LineCap::Butt => 0, + LineCap::Round => 1, + LineCap::Square => 2, + } +} + +/// The text document's number for a stroke's join (key 62), as PostScript numbers them: 0 miter, 1 +/// round, 2 bevel. +fn join_code(join: LineJoin) -> i64 { + match join { + LineJoin::Miter => 0, + LineJoin::Round => 1, + LineJoin::Bevel => 2, + } +} + +/// A stroke's miter limit (key 64) as the reader takes it back: at least 1 and at most 500, 10 when +/// it isn't a number at least 1. +fn miter_limit(v: f64) -> f64 { + if v.is_finite() && v >= 1.0 { v.min(500.0) } else { 10.0 } +} + /// `v` as the 16.16 fixed-point number a design vector holds (`wght` 700 is 45875200). fn fixed(v: f32) -> i64 { if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 } @@ -621,7 +662,12 @@ impl TextDoc { } d.extend([(56, V::Bool(fill.is_some())), (57, V::Bool(stroke.is_some()))]); if stroke.is_some() { - d.push((63, V::Small(st.stroke_width / scale))); + d.extend([ + (61, V::Int(cap_code(st.stroke_cap))), + (62, V::Int(join_code(st.stroke_join))), + (63, V::Small(st.stroke_width / scale)), + (64, real(miter_limit(st.stroke_miter_limit))), + ]); } dict(d) } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a18b71705..8e38543a2 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-11 · **Change:** minor (`.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.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 @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` save: stroked type writes its stroke's cap, join and miter limit into the text document (keys 61, 62 and 64, as `.ai` import reads them since #6500), so they come back when the file opens again and no longer count among the attributes of type left out; a run without a stroke doesn't count them either, as they draw nothing. Tests `ai_editing::tests::type_comes_back_as_type`, `an_unstroked_run_with_a_stroke_style_loses_nothing` (follow-up to #6472) | | 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | From c03fe29ad8044a247c0022c32e33004b6371cd9a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 03:41:05 -0800 Subject: [PATCH 40/40] Plug-ins: the web build times the first-view budget and each run's limit The web build had no clock for plug-in runs, so its first view deferred every plug-in live effect until Effect > Refresh Deferred Effects, and a run's wall-clock limit was not enforced there. The deferrals clock and the run deadline now read `web_time::Instant`, which is the system's monotonic clock on the desktop and the page's `performance.now()` in the web build. The web's first view now shares the same 2,000 ms budget, and its runs stop at the 5 s, 60 s and 300 s limits as on the desktop. Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md. --- Cargo.lock | 1 + crates/plugins/Cargo.toml | 4 ++++ crates/plugins/src/deferred.rs | 25 ++++++++++++++----------- crates/plugins/src/export.rs | 3 +-- crates/plugins/src/host.rs | 28 ++++++++++++++-------------- crates/plugins/src/lib.rs | 2 +- crates/plugins/src/runtime.rs | 6 +++--- docs/mcp.md | 4 ++-- docs/plugins.md | 6 +++--- docs/target-app-parity.md | 2 +- 10 files changed, 44 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3a087e84..74b618911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6687,6 +6687,7 @@ dependencies = [ "vectorcraft-geom", "wasmi", "wat", + "web-time", ] [[package]] diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 7c18f2c06..ef21a57ef 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -22,6 +22,10 @@ craft-vectorcraft-format = { workspace = true } sha2 = { version = "0.10", default-features = false } thiserror = { workspace = true } wasmi = { workspace = true } +# The monotonic clock of run deadlines and the first-view budget: `std::time::Instant` on the +# desktop, the page's `performance.now()` in the web build. It is MIT OR Apache-2.0 and already in +# the build through eframe. +web-time = "1" [dev-dependencies] wat = { workspace = true } diff --git a/crates/plugins/src/deferred.rs b/crates/plugins/src/deferred.rs index f817f32d9..1645dd55e 100644 --- a/crates/plugins/src/deferred.rs +++ b/crates/plugins/src/deferred.rs @@ -86,20 +86,15 @@ pub struct Refreshed { pub failed: usize, } -/// Milliseconds on a monotonic clock, `None` where there is none (the web build). +/// Milliseconds on a monotonic clock, `None` from a clock that cannot be read. pub type Clock = Arc Option + Send + Sync>; -/// Milliseconds since the first reading in this process (native builds); `None` on the web build, -/// whose `std` has no monotonic clock. +/// Milliseconds since the first reading in this process, from `web_time::Instant`: the system's +/// monotonic clock on the desktop and the page's `performance.now()` in the web build. fn monotonic() -> Option { - #[cfg(not(target_arch = "wasm32"))] - { - static START: std::sync::OnceLock = std::sync::OnceLock::new(); - let start = START.get_or_init(std::time::Instant::now); - Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)) - } - #[cfg(target_arch = "wasm32")] - None + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + let start = START.get_or_init(web_time::Instant::now); + Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)) } #[derive(Clone, Copy, Debug, PartialEq)] @@ -579,6 +574,14 @@ mod tests { }); } + /// The default clock, which the web build also reads, gives the first view its budget. + #[test] + fn the_default_clock_gives_the_first_view_a_budget() { + let d = Deferrals::new(); + assert!(matches!(d.allowance(), Allowance::Until { ms: OPENING_BUDGET_MS, .. })); + assert!(!d.passed(u64::MAX)); + } + /// A stage drawn with several inputs (the paths of a group) holds one record for each, and is /// listed and counted once. #[test] diff --git a/crates/plugins/src/export.rs b/crates/plugins/src/export.rs index b51fcfdb9..76b1316b7 100644 --- a/crates/plugins/src/export.rs +++ b/crates/plugins/src/export.rs @@ -24,8 +24,7 @@ use crate::{Error, Kind, Limits, Plugin, Result, registry}; /// document: 200 times a filter's base budget. pub const FUEL: u64 = 10_000_000_000; -/// Wall-clock budget of one export, in milliseconds (native builds; the web build has no clock, -/// and the instruction budget alone bounds it there). +/// Wall-clock budget of one export, in milliseconds. pub const WALL_TIME_MS: u64 = 300_000; /// Most warnings an export reports. diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index 474234880..b98407cb7 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -321,29 +321,20 @@ pub struct Report { /// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]). pub(crate) const TIME_BUDGET: &str = "time budget"; -/// When a run must stop (native: a wall-clock deadline). +/// When a run must stop: a deadline on the monotonic clock (`web_time::Instant`, the page's +/// `performance.now()` in the web build). #[derive(Clone, Copy)] pub(crate) struct Deadline { - #[cfg(not(target_arch = "wasm32"))] - at: std::time::Instant, + at: web_time::Instant, } impl Deadline { pub(crate) fn new(wall_time_ms: u64) -> Self { - #[cfg(target_arch = "wasm32")] - let _ = wall_time_ms; - Self { - #[cfg(not(target_arch = "wasm32"))] - at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms), - } + Self { at: web_time::Instant::now() + std::time::Duration::from_millis(wall_time_ms) } } pub(crate) fn passed(&self) -> bool { - #[cfg(not(target_arch = "wasm32"))] - if std::time::Instant::now() >= self.at { - return true; - } - false + web_time::Instant::now() >= self.at } pub(crate) fn check(&self) -> Result<()> { @@ -816,6 +807,15 @@ mod tests { Run::new("test", true, false, Deadline::new(60_000)) } + /// A deadline reads the monotonic clock that the web build also reads: one of 0 ms has + /// passed, and one a day away has not. + #[test] + fn a_deadline_passes_on_the_monotonic_clock() { + assert!(Deadline::new(0).passed()); + assert!(matches!(Deadline::new(0).check(), Err(Error::Limit(l)) if l == TIME_BUDGET)); + assert!(!Deadline::new(86_400_000).passed()); + } + /// Progress is clamped: negative counts are 0 and `done` never passes a known total. #[test] fn progress_is_clamped() { diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 63f879c2f..8b57adbe6 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -9,7 +9,7 @@ //! permission): no WASI, file system, network, clock or randomness. A module that //! imports anything else is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is -//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a +//! also enforced, so infinite loops end with an error. A filter may ask for a //! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it; //! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; //! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 5abaa0bf4..863ec0a3a 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -29,7 +29,7 @@ const FUEL_COSTS: CustomFuelCosts = CustomFuelCosts { bytes_copied_per_fuel: 64, /// the host learns that only once the run has ended. pub const HEAVY_FUEL: u64 = 1_000_000_000; -/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds). +/// Wall-clock budget of a heavy filter's run, in milliseconds. pub const HEAVY_WALL_TIME_MS: u64 = 300_000; /// The limit a run reports when its input and parameters together are past half its memory. @@ -56,8 +56,8 @@ pub struct Limits { pub fuel_per_byte: u64, /// Deepest call nesting. pub max_recursion_depth: usize, - /// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no - /// monotonic clock in `std`, so the fuel budget alone bounds it there. + /// Wall-clock budget for one run, in milliseconds. The web build measures it with the page's + /// `performance.now()`. pub wall_time_ms: u64, /// The most a heavy filter ([`crate::Budget::Heavy`]) is given instead of `fuel_base` and /// `wall_time_ms`: what the host allows, which the module's manifest can ask for but never diff --git a/docs/mcp.md b/docs/mcp.md index 013574e59..0c560193d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7319,8 +7319,8 @@ and the stages with a part of their input too small for the drawing the refresh timed out, failed or have no plug-in. `id` names one object in place of `ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only -deferred for time, and the canvas then draws the result. The web build has no clock for the budget: there the first -view runs no plug-in effect, and Refresh Deferred Effects runs them. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): +deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call +limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?: diff --git a/docs/plugins.md b/docs/plugins.md index 55de6cdaf..3a934c912 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -128,7 +128,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | | Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` | -| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (checked every 20 M instructions; the web build reads the page's `performance.now()`) | `Limit("time budget")` | | Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that | | Call depth | 1,024 frames | trap | | Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | @@ -147,8 +147,8 @@ refuses or has no plug-in, draws its input art unchanged and is deferred. The st are deferred, and later drawings leave them as they are until their input or parameters change. Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget, and `effect.deferredStatus` lists them with the reason (`budget`, `timeout`, `missing`, `failed`, `safeMode`). Each document has its own budget. -Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has no clock for the -budget, so there the first view runs no live effect and Refresh Deferred Effects runs them. +Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has the same budget, +measured with the page's `performance.now()`. ### Heavy filters diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 31cb7837c..3547a6ba5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1650,7 +1650,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. -- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. +- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.