From 2c6a0b88db838366852926c482abbe4b7a01e655 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:42:36 -0800 Subject: [PATCH 01/14] Number fields: a panel scrolled past a number field keeps scrolling A number field took every wheel turn that came while the pointer was over it. When a panel scrolled a field under a still pointer, the field stepped its value with each turn that reached it. Number fields now follow the rule #6375 gave the closed font family field. A field takes the wheel only when the wheel stream began over it and the pointer was moved onto it, or pressed on it, after the field last moved or was covered by a window or overlay. A stream that began anywhere else stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field. A stream that leaves its field does not return to it. It leaves when the pointer moves off the field or out of the window, and when the field moves or misses a layout pass. Trackpad momentum never starts a stream on a field. When some of a frame's wheel turns land on the field and some land elsewhere, the field takes none of them and the panel scrolls with all of them. The rule and its 0.5 s are VectorCraft's own. Moving the pointer from one number field to another within 0.5 s of the last wheel turn now scrolls the panel until the wheel pauses or the pointer is pressed on the new field. The stream state and the 0.5 s gap move to widgets/wheel_stream.rs, which both kinds of field use. --- crates/ui-egui/src/font_menu.rs | 50 +--- crates/ui-egui/src/font_menu/tests.rs | 47 +++- .../ui-egui/src/panel_docking/native_tests.rs | 4 +- crates/ui-egui/src/tests/font_menu.rs | 2 +- crates/ui-egui/src/tests/numfields.rs | 233 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 4 + crates/ui-egui/src/widgets/numeric.rs | 210 +++++++++++++++- crates/ui-egui/src/widgets/wheel_stream.rs | 66 +++++ docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 10 files changed, 556 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/src/widgets/wheel_stream.rs diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index d0750d373..c59ef6f8c 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -26,6 +26,7 @@ use vectorcraft_text::FontClass; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets; +use crate::widgets::{ArmedWheelField, WheelStream, WheelStreamOwner}; /// What happened in the menu this frame. #[derive(Clone, Debug, PartialEq)] @@ -443,54 +444,6 @@ struct WheelField { layer: egui::LayerId, } -/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. -pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; - -/// The field the pointer was last moved onto, while that field stays where it was. -#[derive(Clone, Copy)] -struct ArmedWheelField { - id: egui::Id, - rect: egui::Rect, - pass: u64, -} - -#[derive(Clone, Copy, PartialEq)] -enum WheelStreamOwner { - Field(egui::Id), - Elsewhere, -} - -/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included. -#[derive(Clone, Copy, Default)] -struct WheelStream { - last: Option, - owner: Option, -} - -impl WheelStream { - /// Note wheel input at `now`; a gap since the last input starts a new stream with no owner. - fn note_input(&mut self, now: f64) { - if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) { - self.owner = None; - } - self.last = Some(now); - } - - /// Leave the rest of the stream to the panel when `field` holds it. - fn release(&mut self, field: egui::Id) { - if self.owner == Some(WheelStreamOwner::Field(field)) { - self.owner = Some(WheelStreamOwner::Elsewhere); - } - } - - /// Leave the rest of the stream to the panel when any field holds it. - fn release_any(&mut self) { - if let Some(WheelStreamOwner::Field(field)) = self.owner { - self.release(field); - } - } -} - #[derive(Clone)] struct QueuedWheel { #[cfg(not(target_arch = "wasm32"))] @@ -941,6 +894,7 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { fields.pointer = pointer; ctx.data_mut(|d| d.insert_temp(key, fields)); if captured { + ctx.data_mut(|d| d.insert_temp(widgets::taken_from_raw_id(raw.viewport_id), now)); ctx.request_repaint(); } } diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 6e0d3bba6..75d2810e5 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -396,6 +396,45 @@ fn stream_context(top: f32) -> (egui::Context, egui::Pos2) { (ctx, point) } +/// A wheel stream the family field took stays its own when the pointer moves onto a number field +/// beside it: the number field steps only after the wheel pauses for the stream gap. +#[test] +fn a_stream_the_family_field_took_leaves_a_number_field_beside_it() { + let ctx = egui::Context::default(); + let value = std::cell::Cell::new(10.0); + let (family, number) = (std::cell::Cell::new(egui::Pos2::ZERO), std::cell::Cell::new(egui::Pos2::ZERO)); + let frame = |time: f64, events: Vec| { + let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() }; + super::take_raw_wheel(&ctx, &mut raw); + let mut notches = 0; + let mut out = ctx.run_ui(raw, |ui| { + let resp = ui.put(egui::Rect::from_min_size(egui::pos2(10.0, 40.0), egui::vec2(120.0, 24.0)), egui::Button::new("Family")); + family.set(resp.rect.center()); + for turn in super::remember_wheel_field(ui, &resp, true) { + if let super::WheelInput::Notches(n) = turn { + notches += n; + } + } + ui.add_space(40.0); + let top = ui.cursor().top(); + if let Some(v) = crate::widgets::plain_field(ui, "size", value.get(), "", 0, 80.0) { + value.set(v); + } + number.set(egui::pos2(ui.min_rect().left() + 20.0, (top + ui.min_rect().bottom()) / 2.0)); + }); + out.textures_delta.clear(); + notches + }; + for i in 0..3 { + frame(100.0 + f64::from(i), vec![]); + } + assert_eq!(frame(110.0, vec![egui::Event::PointerMoved(family.get()), plain_turn()]), 1, "the family field took the turn"); + frame(110.1, vec![egui::Event::PointerMoved(number.get()), plain_turn()]); + assert_eq!(value.get(), 10.0, "the stream began on the family field"); + frame(110.1 + crate::widgets::WHEEL_STREAM_GAP + 0.1, vec![plain_turn()]); + assert_eq!(value.get(), 9.0, "a new stream"); +} + /// A field that moved under a pointer that stayed put leaves the wheel, including a reported /// pointer position that did not change, until the pointer really moves. #[test] @@ -479,7 +518,7 @@ fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() { let (ctx, point) = stream_context(40.0); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); assert_eq!((left, notches), (0, 1)); - let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + super::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + crate::widgets::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "the same stream"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "a new stream, with the pointer still on the field"); @@ -491,14 +530,14 @@ fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() { fn a_wheel_stream_that_began_elsewhere_stays_there_until_a_gap() { let (ctx, point) = stream_context(40.0); let away = point + egui::vec2(0.0, 200.0); - let step = super::WHEEL_STREAM_GAP * 0.8; + let step = crate::widgets::WHEEL_STREAM_GAP * 0.8; let (left, _, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), plain_turn()]); assert_eq!(left, 1, "the stream began off the field"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + step, vec![egui::Event::PointerMoved(point), plain_turn()]); assert_eq!((left, notches), (1, 0), "a nudge onto the field does not take a running stream"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![plain_turn()]); assert_eq!((left, notches), (1, 0), "each event renews the stream"); - let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + super::WHEEL_STREAM_GAP, vec![plain_turn()]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + crate::widgets::WHEEL_STREAM_GAP, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "the stream ended, and the pointer was moved onto the field"); } @@ -548,7 +587,7 @@ fn trackpad_momentum_stays_with_its_stream() { use egui::{MouseWheelSource::Momentum, MouseWheelSource::Trackpad, TouchPhase}; let (ctx, point) = stream_context(40.0); let away = point + egui::vec2(0.0, 200.0); - let step = super::WHEEL_STREAM_GAP * 0.8; + let step = crate::widgets::WHEEL_STREAM_GAP * 0.8; stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), stream_turn(TouchPhase::Move, Trackpad, -1.0)]); // The fingers lift with no delta, the pointer moves onto the field, and momentum follows. stream_frame(&ctx, Some(40.0), 110.0 + step, vec![stream_turn(TouchPhase::End, Trackpad, 0.0)]); diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index de0bc1db7..db1e6be36 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -1054,7 +1054,7 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { h.host.borrow_mut().child_overlay = None; h.frame(vec![]); - h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP; + h.host.borrow_mut().time += crate::widgets::WHEEL_STREAM_GAP; h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ Event::PointerMoved(pointer + vec2(1.0, 0.0)), Event::MouseWheel { @@ -1088,7 +1088,7 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { h.host.borrow_mut().child_transparent_overlay = None; h.host.borrow_mut().child_pass_through_overlay = Some((viewport, field.expand(10.0))); h.frames(2); - h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP; + h.host.borrow_mut().time += crate::widgets::WHEEL_STREAM_GAP; h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ Event::PointerMoved(pointer + vec2(2.0, 0.0)), Event::MouseWheel { diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index 431f2f982..ea79c1d5c 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -410,7 +410,7 @@ fn wheel_events_use_the_pointer_position_when_they_occurred() { assert_eq!(first_style(&app, id).font_family, "Inter", "the wheel was outside the field"); assert_eq!(app.session.active().unwrap().history.undo.len(), before); assert!(ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. }))), "outside wheel input is left for its owner"); - let after_gap = |ctx: &egui::Context| Some(ctx.input(|i| i.time) + crate::font_menu::WHEEL_STREAM_GAP); + let after_gap = |ctx: &egui::Context| Some(ctx.input(|i| i.time) + crate::widgets::WHEEL_STREAM_GAP); let later = after_gap(&ctx); panel_frame_at(&ctx, &mut app, later, vec![wheel(line, -1.0, Default::default()), egui::Event::PointerMoved(outside)], "Inter", &draw); assert_ne!(first_style(&app, id).font_family, "Inter", "the wheel was over the field before the pointer left"); diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index d83938555..45801c22e 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1007,6 +1007,239 @@ fn the_wheel_scrolls_the_panel_unless_over_a_numeric_field() { } } +/// A wheel notch of `dy` lines (up positive) from `source`, with no pointer movement. +fn notch(dy: f32, source: egui::MouseWheelSource) -> Event { + Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, source } +} + +/// A notch down from a mouse wheel. +fn notch_down() -> Event { + notch(-1.0, egui::MouseWheelSource::Wheel) +} + +/// A panel scrolled with a still pointer keeps scrolling when a number field slides under the +/// pointer, and the field keeps its value. The field used to take each notch that came while it +/// was under the pointer. +#[test] +fn scrolling_a_panel_past_a_number_field_under_a_still_pointer_keeps_the_value() { + let mut f = Field::new(); + let offset = Cell::new(0.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.add_space(60.0); + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + ui.add_space(600.0); + v + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![Event::PointerMoved(Pos2::new(20.0, 20.0))], &draw); + let mut stepped = None; + for _ in 0..30 { + stepped = stepped.or(f.frame(vec![notch_down()], &draw)); + } + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((stepped, value.get()), (None, 10.0), "the field kept its value"); + assert!(offset.get() > 200.0, "the panel scrolled the field past the pointer: {}", offset.get()); +} + +/// A number field that slid under a still pointer takes the wheel only after the pointer is moved +/// onto it; an event at the same position is not a move. Until then the panel scrolls. +#[test] +fn a_number_field_that_slid_under_a_still_pointer_steps_after_the_pointer_moves() { + let mut f = Field::new(); + let top = Cell::new(60.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + ui.add_space(top.get()); + widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)) + }; + f.frame(vec![], &draw); + let at = Pos2::new(20.0, 20.0); + f.frame(vec![Event::PointerMoved(at)], &draw); + top.set(0.0); + f.frame(vec![], &draw); + assert!(f.rect.get().contains(at), "the field is under the pointer"); + for _ in 0..2 { + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the pointer did not move"); + } + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at), notch_down()], &draw), None, "the same position"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + let moved = Pos2::new(21.0, 20.0); + assert_eq!(f.frame(vec![Event::PointerMoved(moved), notch_down()], &draw), Some(9.0)); + // The field moves again under the pointer, in the frame of a turn: the turn is not the field's, + // and the field waits for another move. + top.set(2.0); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the field moved in the frame of the turn"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the field moved"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at), notch_down()], &draw), Some(8.0)); +} + +/// A wheel stream stays with what it began on until it pauses for the stream gap: one that began +/// off the field never steps it, one that left the field (the pointer moved off it or out of the +/// window) does not come back to it, and trackpad momentum never starts one on the field. +#[test] +fn a_number_field_wheel_stream_stays_with_what_it_began_on() { + use egui::MouseWheelSource::{Momentum, Trackpad}; + let mut f = Field::new(); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + f.frame(vec![], &draw); + let (on, off) = (f.rect.get().center(), Pos2::new(300.0, 150.0)); + let gap = |f: &mut Field| f.time += crate::widgets::WHEEL_STREAM_GAP; + // Began on the field, left it, came back within the stream. + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + assert_eq!(f.frame(vec![Event::PointerMoved(off), notch_down()], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "left its field"); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0), "a new stream"); + // Began off the field. + f.frame(vec![Event::PointerMoved(off)], &draw); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "began off the field"); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(7.0)); + // The pointer left the window. + assert_eq!(f.frame(vec![Event::PointerGone], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "left the window"); + // Momentum after a pause starts no stream on the field, and the stream it starts stays off it. + gap(&mut f); + assert_eq!(f.frame(vec![notch(-1.0, Momentum)], &draw), None, "momentum"); + assert_eq!(f.frame(vec![notch(-1.0, Trackpad)], &draw), None, "the momentum's stream"); + gap(&mut f); + assert_eq!(f.frame(vec![notch(-1.0, Trackpad)], &draw), Some(6.0)); + assert_eq!(value.get(), 6.0); +} + +/// A frame whose turns begin on the field and go on after the pointer moved off it scrolls the +/// panel with all of them and leaves the value. +#[test] +fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() { + let mut f = Field::new(); + let offset = Cell::new(0.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + ui.add_space(600.0); + v + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + let (on, off) = (Pos2::new(20.0, 12.0), Pos2::new(20.0, 80.0)); + let events = vec![Event::PointerMoved(on), notch_down(), Event::PointerMoved(off), notch_down()]; + assert_eq!(f.frame(events, &draw), None); + assert!(offset.get() > 0.0, "the panel scrolled in the same frame"); + assert_eq!(value.get(), 10.0); +} + +/// A number field that missed a pass (its panel was hidden) waits for the pointer to move onto it +/// again before it takes the wheel, with or without another number field drawn meanwhile. +#[test] +fn a_number_field_that_missed_a_pass_waits_for_a_pointer_move() { + for other in [false, true] { + let mut f = Field::new(); + let shown = Cell::new(true); + let draw = |ui: &mut egui::Ui| { + let v = if shown.get() { widgets::plain_field(ui, "f", 10.0, "", 0, 80.0) } else { None }; + if other { + ui.add_space(100.0); + widgets::plain_field(ui, "other", 1.0, "", 0, 80.0); + } + v + }; + f.frame(vec![], &draw); + let at = Pos2::new(20.0, 12.0); + f.frame(vec![Event::PointerMoved(at)], &draw); + shown.set(false); + f.frame(vec![], &draw); + shown.set(true); + f.frame(vec![], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "missed a pass, other field {other}"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at + vec2(1.0, 0.0)), notch_down()], &draw), Some(9.0), "other field {other}"); + } +} + +/// Two number fields, `a` above `b`; the frame returns `b`'s new value, and `a`'s goes in `a`. +fn two_fields<'a>(a: &'a Cell, b: &'a Cell, rects: &'a Cell<[Rect; 2]>) -> impl Fn(&mut egui::Ui) -> Option + 'a { + move |ui: &mut egui::Ui| { + if let Some(v) = widgets::plain_field(ui, "a", a.get(), "", 0, 80.0) { + a.set(v); + } + let first = ui.min_rect(); + ui.add_space(40.0); + let top = ui.cursor().top(); + let v = widgets::plain_field(ui, "b", b.get(), "", 0, 80.0).inspect(|&v| b.set(v)); + rects.set([first, Rect::from_x_y_ranges(first.x_range(), top..=ui.min_rect().bottom())]); + v + } +} + +/// A stream that began on one number field scrolls the panel when the pointer moves onto another +/// field, until the wheel pauses for the stream gap or the pointer is pressed on the other field. +#[test] +fn a_press_on_a_number_field_gives_it_the_running_wheel_stream() { + let mut f = Field::new(); + let (a, b, rects) = (Cell::new(10.0), Cell::new(20.0), Cell::new([Rect::NOTHING; 2])); + let draw = two_fields(&a, &b, &rects); + f.frame(vec![], &draw); + let [on_a, on_b] = rects.get().map(|r| r.center()); + f.frame(vec![Event::PointerMoved(on_a), notch_down()], &draw); + assert_eq!(a.get(), 9.0, "the stream began on a"); + assert_eq!(f.frame(vec![Event::PointerMoved(on_b), notch_down()], &draw), None, "moved onto b within the stream"); + let press = |pressed| Event::PointerButton { pos: on_b, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + f.frame(vec![press(true), press(false)], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(19.0), "pressed on b"); + assert_eq!(a.get(), 9.0); + // A press on the field that holds the stream keeps it there. + f.frame(vec![press(true), press(false)], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(18.0)); +} + +/// A window or overlay that comes and goes over a still pointer, with no input while it is up, +/// leaves the number field under it waiting for the pointer to move onto it again. +#[test] +fn a_number_field_covered_by_an_overlay_waits_for_a_pointer_move() { + let mut f = Field::new(); + let value = Cell::new(10.0); + let covered = Cell::new(false); + let draw = |ui: &mut egui::Ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + if covered.get() { + let cover = ui.min_rect().expand(5.0); + egui::Area::new(egui::Id::unique("numfields overlay")).order(egui::Order::Foreground).fixed_pos(cover.min).show(ui.ctx(), |ui| { + ui.allocate_exact_size(cover.size(), egui::Sense::click()); + }); + } + v + }; + f.frame(vec![], &draw); + let at = f.rect.get().center(); + f.frame(vec![Event::PointerMoved(at)], &draw); + covered.set(true); + f.frame(vec![], &draw); + f.frame(vec![], &draw); + covered.set(false); + f.frame(vec![], &draw); + f.frame(vec![], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the overlay covered the field"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at + vec2(1.0, 0.0)), notch_down()], &draw), Some(9.0)); +} + /// A wheel turn over an unfocused field steps it without consuming an arrow key in the same frame. #[test] fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f3243c75..c70cca173 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -20,6 +20,7 @@ mod numeric; mod panel_chrome; mod text; mod toggles; +mod wheel_stream; pub use blend::{ TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point, @@ -72,6 +73,9 @@ pub use text::{ text_presets_named, }; pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button}; +#[cfg(test)] +pub(crate) use wheel_stream::WHEEL_STREAM_GAP; +pub(crate) use wheel_stream::{ArmedWheelField, WheelStream, WheelStreamOwner, taken_from_raw_id}; /// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also /// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 04ac7160f..36184395b 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -5,6 +5,7 @@ use super::panel_chrome::{ use super::text::{ after_shift_enter, focus_field, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_armed_focus, take_dialog_focus, }; +use super::{ArmedWheelField, WheelStream, WheelStreamOwner}; use super::{CornerRadius, RangeInclusive, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Unit, id_with, pos2, scrub, vec2}; /// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it @@ -247,9 +248,10 @@ pub(super) fn shown_alike(show: impl Fn(f64) -> String) -> impl Fn(f64, f64) -> /// a weight stops at 0, a miter limit at 1), and a plain step also stops at each of `stops` /// ([`step_through`]). The new value is applied at once, as in /// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing -/// replaces it when focused. Returns the new number when a step was taken. While the pointer is -/// over an enabled field the wheel is the field's (the panel under it doesn't scroll), without -/// taking keyboard focus. Arrow keys still require keyboard focus. +/// replaces it when focused. Returns the new number when a step was taken. An enabled field takes +/// the wheel without taking keyboard focus, and the panel under it doesn't scroll, when the wheel +/// stream began over the field and the pointer was moved onto it, or pressed on it, after the +/// field last moved or was covered ([`number_wheel_turns`]). Arrow keys still require keyboard focus. fn step_value( ui: &Ui, resp: &Response, @@ -266,8 +268,10 @@ fn step_value( } let focused = ui.memory(|m| m.has_focus(resp.id)); use egui::{Event, Key, MouseWheelUnit}; - let wheel = resp.contains_pointer(); - if !focused && !wheel { + let (taken, owns) = number_wheel_turns(ui, resp); + let wheel = !taken.is_empty(); + let hold = wheel || owns && resp.contains_pointer(); + if !focused && !hold { return None; } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is @@ -277,14 +281,18 @@ fn step_value( // Each press and each turn of whole notches in the order they came: its modifiers and size from // [`STEPS`], and how many steps up (down when negative). let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); + let mut taken = taken; ui.input_mut(|i| { i.events.retain(|e| { + // Each taken event is matched once by its value, so wheel events that other code adds + // or removes before the field runs do not shift which events the field takes. + let mine = taken.iter().position(|t| t == e).map(|at| taken.swap_remove(at)).is_some(); let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; let n = match e { Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0, Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0, - Event::MouseWheel { unit, delta, .. } if wheel => { + Event::MouseWheel { unit, delta, .. } if mine => { // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; notches += match unit { @@ -302,7 +310,7 @@ fn step_value( } false }); - if wheel { + if hold { // The rest of a turn egui spreads over the next frames scrolls nothing either. i.smooth_scroll_delta = egui::Vec2::ZERO; } @@ -335,6 +343,194 @@ fn step_value( Some(v) } +/// An enabled number field as the wheel finds it. +#[derive(Clone, Copy)] +struct WheelTarget { + id: egui::Id, + rect: Rect, + layer: egui::LayerId, +} + +/// Where the pointer was, the field it was last moved onto, and the running wheel stream. +#[derive(Clone, Copy, Default)] +struct WheelWalk { + pointer: Option, + armed: Option, + stream: WheelStream, +} + +/// The number fields' wheel state in one viewport. +#[derive(Clone, Default)] +struct NumberWheel { + /// The pass whose events `walk` and `taken` cover. + pass: Option, + /// The enabled number fields drawn in the pass before `pass`. + before: Vec, + /// The enabled number fields drawn so far in `pass`. + drawn: Vec, + walk: WheelWalk, + /// Each wheel event of `pass` that a field takes, with the field. + taken: Vec<(egui::Id, egui::Event)>, +} + +/// The wheel events of this pass that number field `resp` takes, and whether the field holds the +/// wheel stream after them. The first field drawn in a pass reads the pass's events for every +/// field, using where the fields were drawn in the pass before. A field takes a wheel stream only +/// when the stream begins with the pointer over it and the pointer was moved onto it, or pressed +/// on it, after the field last moved or was covered by a window or overlay. A stream that began +/// anywhere else stays there until it ends ([`WheelStream`]) or the pointer is pressed on a field, +/// and a stream that leaves its field does not return to it. Trackpad momentum never starts a +/// stream on a field. +fn number_wheel_turns(ui: &Ui, resp: &Response) -> (Vec, bool) { + let ctx = ui.ctx(); + let key = egui::Id::unique("number-field-wheel").with(ctx.viewport_id()); + let pass = ctx.cumulative_pass_nr(); + let font_key = super::taken_from_raw_id(ctx.viewport_id()); + let fresh = ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::(key); + if state.pass == Some(pass) { + return None; + } + let follows = state.pass.is_some_and(|last| last + 1 == pass); + state.before = if follows { std::mem::take(&mut state.drawn) } else { Vec::new() }; + state.drawn.clear(); + state.pass = Some(pass); + if !follows { + state.walk.pointer = None; + state.walk.armed = None; + state.walk.stream.release_any(); + } + // Turns the font family field took from the raw input belong to its stream. + let font_took = d.remove_temp::(font_key); + let state = d.get_temp_mut_or_default::(key); + if let Some(at) = font_took { + state.walk.stream.note_taken_elsewhere(at); + } + // A field that missed a pass may have moved. + if let Some(armed) = state.walk.armed + && !state.before.iter().any(|t| t.id == armed.id) + { + state.walk.armed = None; + state.walk.stream.release(armed.id); + } + Some((state.walk, state.before.clone())) + }); + let walked = fresh.map(|(walk, before)| { + let (events, now) = ui.input(|i| (i.events.clone(), i.time)); + walk_wheel(ctx, walk, &events, &before, now, pass) + }); + let target = WheelTarget { id: resp.id, rect: resp.interact_rect, layer: resp.layer_id }; + let (armed_here, pointer) = ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::(key); + if let Some((walk, taken)) = walked { + state.walk = walk; + state.taken = taken; + } + (state.walk.armed.is_some_and(|armed| armed.id == resp.id), state.walk.pointer) + }); + // A window or overlay over the pointer covers the armed field even when no input arrives. + let covered = armed_here && pointer.is_some_and(|p| target.rect.contains(p) && ctx.layer_id_at(p) != Some(target.layer)); + ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::(key); + if let Some(armed) = &mut state.walk.armed + && armed.id == resp.id + { + if armed.rect == target.rect && !covered { + armed.pass = pass; + } else { + // The field moved or is covered, so the pass's turns stay with the panel. + state.walk.armed = None; + state.walk.stream.release(resp.id); + state.taken.retain(|&(id, _)| id != resp.id); + } + } + state.drawn.push(target); + let mut mine = Vec::new(); + state.taken.retain(|(id, event)| { + if *id == resp.id { + mine.push(event.clone()); + } + *id != resp.id + }); + (mine, state.walk.stream.owner == Some(WheelStreamOwner::Field(resp.id))) + }) +} + +/// `walk` after `events`, with `fields` where the pointer finds them, at input time `now`; and the +/// wheel events a field takes (see [`NumberWheel::taken`]). +fn walk_wheel( + ctx: &egui::Context, + mut walk: WheelWalk, + events: &[egui::Event], + fields: &[WheelTarget], + now: f64, + pass: u64, +) -> (WheelWalk, Vec<(egui::Id, egui::Event)>) { + let under = |p: Option| { + let p = p?; + let layer = ctx.layer_id_at(p); + fields.iter().rev().find(|t| t.rect.contains(p) && layer == Some(t.layer)).copied() + }; + let mut taken = Vec::new(); + let mut left = false; + for event in events { + match event { + egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } => { + let pressed = matches!(event, egui::Event::PointerButton { pressed: true, .. }); + if walk.pointer != Some(*p) || pressed { + let field = under(Some(*p)); + walk.armed = field.map(|t| ArmedWheelField { id: t.id, rect: t.rect, pass }); + if let Some(WheelStreamOwner::Field(id)) = walk.stream.owner + && field.is_none_or(|t| t.id != id) + { + walk.stream.release(id); + } + // A press on a field gives it the rest of a running stream. + if pressed + && let Some(t) = field + && walk.stream.owner != Some(WheelStreamOwner::Field(t.id)) + { + walk.stream.owner = None; + } + } + walk.pointer = Some(*p); + } + egui::Event::PointerGone => { + walk.pointer = None; + walk.armed = None; + walk.stream.release_any(); + } + egui::Event::MouseWheel { delta, source, .. } => { + walk.stream.note_input(now); + let field = under(walk.pointer); + if *delta != egui::Vec2::ZERO { + let starts = field + .filter(|t| walk.armed.is_some_and(|armed| armed.id == t.id) && *source != egui::MouseWheelSource::Momentum) + .map_or(WheelStreamOwner::Elsewhere, |t| WheelStreamOwner::Field(t.id)); + let owner = *walk.stream.owner.get_or_insert(starts); + if field.is_none_or(|t| owner != WheelStreamOwner::Field(t.id)) { + walk.stream.owner = Some(WheelStreamOwner::Elsewhere); + } + } + if let Some(t) = field + && walk.stream.owner == Some(WheelStreamOwner::Field(t.id)) + { + taken.push((t.id, event.clone())); + } else { + left |= *delta != egui::Vec2::ZERO; + } + } + _ => {} + } + } + // egui smooths a pass's wheel input as a whole before any field runs, so a field cannot take + // part of it: a pass whose wheel input also scrolls something else is left whole to the panel. + if left { + taken.clear(); + } + (walk, taken) +} + /// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter /// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A /// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). diff --git a/crates/ui-egui/src/widgets/wheel_stream.rs b/crates/ui-egui/src/widgets/wheel_stream.rs new file mode 100644 index 000000000..fe4820042 --- /dev/null +++ b/crates/ui-egui/src/widgets/wheel_stream.rs @@ -0,0 +1,66 @@ +//! Wheel streams for the fields that step on the wheel (number fields and the closed font family +//! field). A field takes a wheel stream only when the stream begins with the pointer over it and +//! the pointer was moved onto it after the field last moved. + +/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. +pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; + +/// Where the closed font family field, which takes its wheel events from the raw input, keeps the +/// input time of the last one it took in `viewport`, so number fields see that stream too. +pub(crate) fn taken_from_raw_id(viewport: egui::ViewportId) -> egui::Id { + egui::Id::unique("wheel-taken-from-raw").with(viewport) +} + +/// The field the pointer was last moved onto, while that field stays where it was. +#[derive(Clone, Copy)] +pub(crate) struct ArmedWheelField { + pub(crate) id: egui::Id, + pub(crate) rect: egui::Rect, + pub(crate) pass: u64, +} + +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum WheelStreamOwner { + Field(egui::Id), + Elsewhere, +} + +/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included. +#[derive(Clone, Copy, Default)] +pub(crate) struct WheelStream { + last: Option, + pub(crate) owner: Option, +} + +impl WheelStream { + /// Note wheel input at `now`; a gap since the last input starts a new stream with no owner. + pub(crate) fn note_input(&mut self, now: f64) { + if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) { + self.owner = None; + } + self.last = Some(now); + } + + /// Note wheel input at `at` that another kind of field took: the stream it belongs to is not + /// one of this kind's fields. + pub(crate) fn note_taken_elsewhere(&mut self, at: f64) { + if self.last.is_none_or(|last| last <= at) { + self.note_input(at); + } + self.owner = Some(WheelStreamOwner::Elsewhere); + } + + /// Leave the rest of the stream to the panel when `field` holds it. + pub(crate) fn release(&mut self, field: egui::Id) { + if self.owner == Some(WheelStreamOwner::Field(field)) { + self.owner = Some(WheelStreamOwner::Elsewhere); + } + } + + /// Leave the rest of the stream to the panel when any field holds it. + pub(crate) fn release_any(&mut self) { + if let Some(WheelStreamOwner::Field(field)) = self.owner { + self.release(field); + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7610075f6..5d91cd61b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -804,7 +804,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. A field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere, such as a panel scrolled under a still pointer, stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field (VectorCraft's own rule and value). - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44a25a180..922c5f974 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -267,7 +267,7 @@ tools, never checked side by side) isn't captured row by row. | Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. This rule and its 0.5 s are VectorCraft's choices. Other font-picking menus and modifier-wheel input keep their existing behavior. | | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | -| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | +| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field, so a panel scrolled under a still pointer keeps scrolling past the field. This rule and its 0.5 s are VectorCraft's choices. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | From 9172950ad50349b186f23dba6d4d18ba429ad5c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 19:34:24 -0800 Subject: [PATCH 02/14] MCP: command listings give each command with a schema an inputSchema engine.commands, command_list and list_commands now list an inputSchema next to schema for each command with a parameter schema. It is the same JSON Schema 2020-12 schema with the root type "object", the form an MCP tool's inputSchema takes (https://modelcontextprotocol.io/specification/2026-07-28/schema); schema keeps the root type ["object", "null"] when no parameter is required. A command without a schema lists neither field. tools/list is unchanged, and the commands are still run with command_run or run_command. brief now leaves out schema and inputSchema as well as the params text; a listing filtered to the command's id gives them. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd.rs | 18 ++++++++- crates/engine/src/schemas.rs | 60 +++++++++++++++++++++++++++++ crates/mcp/src/tests/conventions.rs | 49 ++++++++++++++++++++++- crates/mcp/src/tools/definitions.rs | 4 +- crates/mcp/src/tools/dispatch.rs | 9 +++-- docs/mcp.md | 15 ++++++-- docs/target-app-parity.md | 1 + 7 files changed, 144 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 4042f0a13..a68703f11 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -149,11 +149,26 @@ pub struct CommandInfo { /// The parameters' schema as a JSON Schema 2020-12 object schema, for a command that has one. #[serde(skip_serializing_if = "Option::is_none")] pub schema: Option, + /// The same schema in the form an MCP tool's `inputSchema` takes ([`input_schema`]). + #[serde(rename = "inputSchema", skip_serializing_if = "Option::is_none")] + pub input_schema: Option, +} + +/// `schema` as an MCP tool input schema: the root `type` is `"object"`, since the arguments are +/// always an object, and every other keyword is kept. The root of [`CommandInfo::schema`] also +/// takes null when no parameter is required. +pub(crate) fn input_schema(schema: &Value) -> Value { + let mut o = schema.clone(); + if let Some(map) = o.as_object_mut() { + map.insert("type".into(), "object".into()); + } + o } impl CommandSpec { pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); + let schema = self.schema.map(vectorcraft_apischema::ParamSchema::to_json); CommandInfo { id: self.id, label: self.label, @@ -162,7 +177,8 @@ impl CommandSpec { params: self.params, enabled: e.is_ok(), disabled_reason: e.err(), - schema: self.schema.map(vectorcraft_apischema::ParamSchema::to_json), + input_schema: schema.as_ref().map(input_schema), + schema, } } } diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index c70c4bb82..a24613a07 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -323,6 +323,66 @@ mod tests { assert!(without.get("schema").is_none()); } + /// The listing gives each command with a schema an `inputSchema`, the form an MCP tool's input + /// schema takes: the same schema with the root `type` `"object"`, and every other keyword kept, + /// nested parameters, required names, defaults and VectorCraft's own fields included. A command + /// with no schema lists neither. + #[test] + fn engine_commands_list_an_input_schema_with_an_object_root() { + let s = Session::new(); + let listed: Vec = s.commands().iter().map(|c| serde_json::to_value(c).unwrap()).collect(); + let mut with_schema = 0; + for c in &listed { + let id = c["id"].as_str().unwrap(); + let Some(schema) = c.get("schema") else { + assert!(c.get("inputSchema").is_none(), "{id}"); + continue; + }; + with_schema += 1; + let input = c.get("inputSchema").unwrap_or_else(|| panic!("{id} has no inputSchema")); + assert_eq!(input["type"], "object", "{id}"); + assert_eq!(input["$schema"], vectorcraft_apischema::DIALECT, "{id}"); + check_schema(input, id).unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert!(!instance_valid(input, &Value::Null), "{id}: the arguments are an object"); + let without_type = |v: &Value| { + let mut v = v.clone(); + v.as_object_mut().map(|o| o.remove("type")); + v + }; + assert_eq!(without_type(schema), without_type(input), "{id}"); + } + assert_eq!(with_schema, schemas().len()); + let get = |id: &str| listed.iter().find(|c| c["id"] == id).unwrap(); + // No required parameter: the schema also takes null, the input schema does not. + let list = get("view.saved.list"); + assert_eq!(list["schema"]["type"], json!(["object", "null"])); + assert_eq!(list["inputSchema"]["type"], "object"); + // Required parameters and their constraints are kept. + let new_view = get("view.saved.new"); + assert_eq!(new_view["inputSchema"]["required"], json!(["center", "zoom"])); + assert_eq!(new_view["inputSchema"]["properties"], new_view["schema"]["properties"]); + // Nested objects in an array keep their keys. + let edit = &get("view.saved.edit")["inputSchema"]["properties"]["edits"]; + assert!(edit.to_string().contains("newName"), "{edit}"); + assert!(get("file.new").get("inputSchema").is_none()); + } + + /// A deprecated command and a deprecated parameter keep their deprecation in the input schema. + #[test] + fn an_input_schema_keeps_deprecation() { + static PARAMS: [Param; 2] = [Param::new("old", Type::Boolean).optional().deprecated(1), Param::new("on", Type::Boolean).optional()]; + let schema = ParamSchema::new(&PARAMS, "Old command.").deprecated(1).to_json(); + let input = crate::cmd::input_schema(&schema); + check_schema(&input, "synthetic").unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert_eq!(schema["type"], json!(["object", "null"])); + assert_eq!(input["type"], "object"); + assert_eq!(input["deprecated"], true); + assert_eq!(input["x-deprecatedSince"], 1); + assert_eq!(input["properties"]["old"], schema["properties"]["old"]); + assert_eq!(input["properties"]["old"]["deprecated"], true); + assert_eq!(input["description"], "Old command."); + } + /// The listing says what each View command changes when it runs. Only the commands that make an /// undo step list `document`. #[test] diff --git a/crates/mcp/src/tests/conventions.rs b/crates/mcp/src/tests/conventions.rs index 929851917..0b7352bf4 100644 --- a/crates/mcp/src/tests/conventions.rs +++ b/crates/mcp/src/tests/conventions.rs @@ -57,18 +57,63 @@ fn command_listings_take_query_menu_and_brief() { let menu: Vec<&str> = c["menu"].as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect(); assert_eq!(menu.get(..2), Some(&["Object", "Path"][..]), "{c}"); } - // `brief` drops the params text and nothing else. + // `brief` drops the params text and the schemas, and nothing else. let full = payload(&call(&mut s, "command_list", json!({}))); let brief = payload(&call(&mut s, "command_list", json!({"brief":true}))); assert_eq!(ids(&full), ids(&brief)); assert!(full.as_array().unwrap().iter().any(|c| c.get("params").is_some())); - assert!(brief.as_array().unwrap().iter().all(|c| c.get("params").is_none() && c.get("label").is_some())); + assert!(full.as_array().unwrap().iter().any(|c| c.get("inputSchema").is_some())); + assert!( + brief + .as_array() + .unwrap() + .iter() + .all(|c| c.get("params").is_none() && c.get("schema").is_none() && c.get("inputSchema").is_none() && c.get("label").is_some()) + ); assert!(brief.to_string().len() * 2 < full.to_string().len(), "brief is much shorter"); // The other listing takes them too. let r = payload(&call(&mut s, "list_commands", json!({"query":"rectangle","brief":true,"menu":""}))); assert_eq!(ids(&r["commands"]), ids(&by_filter)); } +#[path = "../../../apischema/src/conformance.rs"] +mod conformance; + +/// Both listings give a command with a parameter schema an `inputSchema` with an object root, +/// which follows the 2020-12 rules, and the filters select the same commands as before. +#[test] +fn command_listings_carry_input_schemas() { + let mut s = Server::new(Box::new(Headless::with_document())); + let list = payload(&call(&mut s, "list_commands", json!({"filter":"view.saved"}))); + let bare = payload(&call(&mut s, "command_list", json!({"filter":"view.saved"}))); + assert_eq!(list["commands"], bare); + assert_eq!(list["count"], bare.as_array().unwrap().len()); + let commands = bare.as_array().unwrap(); + let with_input: Vec<&Value> = commands.iter().filter(|c| c.get("inputSchema").is_some()).collect(); + assert!(with_input.len() >= 3, "{bare}"); + for c in with_input { + let input = &c["inputSchema"]; + assert_eq!(input["type"], "object", "{c}"); + conformance::check_schema(input, c["id"].as_str().unwrap()).unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert!(!conformance::instance_valid(input, &Value::Null)); + assert_eq!(conformance::instance_valid(&c["schema"], &Value::Null), c["schema"]["type"] != "object", "{c}"); + } + let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").unwrap(); + assert_eq!(saved_list["schema"]["type"], json!(["object", "null"])); + assert_eq!(saved_list["inputSchema"]["type"], "object"); + // A command without a schema lists neither. + let new = payload(&call(&mut s, "command_list", json!({"filter":"file.new","enabled_only":true}))); + let new = new.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap().clone(); + assert!(new.get("schema").is_none() && new.get("inputSchema").is_none(), "{new}"); + // `brief`, `menu` and the enabled filter select the same ids with and without the schemas. + let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].clone()).collect::>(); + let view = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true}))); + let view_brief = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true,"brief":true}))); + assert_eq!(ids(&view["commands"]), ids(&view_brief["commands"])); + assert_eq!(view["count"], view_brief["count"]); + assert!(view_brief["commands"].as_array().unwrap().iter().all(|c| c.get("inputSchema").is_none() && c.get("schema").is_none())); +} + #[test] fn conventions_commands_batch_and_bounded_preview() { let mut s = Server::new(Box::new(Headless::with_document())); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 616e32aae..8fe45b83e 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -98,7 +98,7 @@ pub fn tool_definitions() -> Vec { tool( "command_list", "List commands", - "Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", + "Discover command ids, labels, parameter descriptions and enabled state. A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", obj( json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), &[], @@ -156,7 +156,7 @@ pub fn tool_definitions() -> Vec { tool( "list_commands", "List commands", - "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", + "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", obj( json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), &[], diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index eabd73e3e..b1c5af70b 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -10,8 +10,7 @@ pub(super) const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")"; /// The `brief` argument of the command listings. -pub(super) const BRIEF: &str = - "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it"; +pub(super) const BRIEF: &str = "Leave out each command's params description and its schema and inputSchema (the bulk of the catalogue); run command_list with the id as filter to read them"; /// The `max_side` argument of the window captures. pub(super) const MAX_SIDE_WINDOW: &str = @@ -30,7 +29,7 @@ pub(super) fn trace_views(a: &Args) -> Result { /// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the /// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params` -/// text: the full catalogue is too large for many clients, #2173). +/// text, `schema` or `inputSchema`: the full catalogue is too large for many clients, #2173). fn filter_commands(all: Value, a: &Args) -> Value { let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); let needle = text("filter").or_else(|| text("query")); @@ -63,7 +62,9 @@ fn filter_commands(all: Value, a: &Args) -> Value { .filter(hit) .map(|mut c| { if brief && let Some(o) = c.as_object_mut() { - o.remove("params"); + for key in ["params", "schema", "inputSchema"] { + o.remove(key); + } } c }) diff --git a/docs/mcp.md b/docs/mcp.md index 2618ebfa4..e818579c8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -369,10 +369,10 @@ The older names below remain listed because existing workflows use them. | Tool | Arguments and result | |---|---| -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array. `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array. A command with a parameter schema lists `schema` and `inputSchema` (see [Parameter schemas](#parameter-schemas)). `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)). `run_command` runs a command the same way and takes its id as `command` | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` | | `render_preview` | Optional `max_side` (1–4096, default 1024), `artboard` (0-based, default 0) and `traceViews`; inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). With `traceViews: true`, Image Trace objects draw in the view set with `imageTrace.setView` (outlines, the source image…), as in the app's window; by default they draw their tracing result. Artboards too large to render within the allocation bound return a tool error | @@ -446,7 +446,7 @@ opens the picker through `run_command` with the remote backend. | Tool | Arguments | Notes | |---|---|---| -| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | +| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement, and `schema` and `inputSchema` for a command with a parameter schema (the params doc and both schemas are left out with `brief`). | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | @@ -1403,6 +1403,15 @@ JSON Schema defines one, and the command receives `2`. An `x-lenient` parameter' and the engine also passes a value of another type to the command. The command's `params` text equals the text the schema writes, and a test checks that they are the same. +The `engine.commands` list and the `command_list` and `list_commands` tools also give such a command an `inputSchema` +field: the same schema in the form of an MCP tool's `inputSchema` +(https://modelcontextprotocol.io/specification/2025-06-18/schema), whose root `type` is always `"object"` +(https://modelcontextprotocol.io/specification/2026-07-28/schema). It differs from `schema` only in the root `type`, +which is `"object"` also when no parameter is required; every other keyword, `$schema` and the `x-` fields included, +is the same. A command without a schema lists neither field. The commands remain commands run with `command_run` +or `run_command`: `tools/list` does not list them as tools. With `brief`, the listings leave out `schema` and +`inputSchema` as well as the `params` text; a listing with the command's id as `filter` gives them. + The engine checks the params of such a command against its schema before the command runs, for every new call: from the desktop app, the control channel, MCP or the CLI, and when one command runs another. The steps of `command.batch` and `action.batch` are checked before the first step runs. A value of the wrong type, out of range or not in its list, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17cc2849b..31b606249 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -669,6 +669,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. +- **Input schemas in the command listings:** `engine.commands` and the MCP tools `command_list` and `list_commands` give each command with a parameter schema an `inputSchema` next to `schema`: the same schema with the root `type` `"object"`, the form an MCP tool's input schema takes ([MCP schema reference](https://modelcontextprotocol.io/specification/2026-07-28/schema)). A command without a schema lists neither, and `brief` leaves out both schemas with the `params` text. Tests: `schemas::tests::engine_commands_list_an_input_schema_with_an_object_root` and `schemas::tests::an_input_schema_keeps_deprecation` in `vectorcraft-engine`, and `tests::conventions::command_listings_carry_input_schemas` in `vectorcraft-mcp`, which check every listed input schema against the JSON Schema 2020-12 rules. - **Automation:** - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value. From fd0be1aca044d5e1fccec374f3fe6bd71c3343a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 19:57:27 -0800 Subject: [PATCH 03/14] 3D: a profile's empty contours count toward its point limit revolve_curve_samples counted a profile's contours but never compared the count with the limit, so a profile made only of empty or one-anchor subpaths passed, although each contour takes one profile point. HoleSearch::new then built one entry for each subpath without a limit, and mesh export skips revolve_curve_samples when curve_samples is given. Both now refuse a profile with more than 16,384 contours as too complex. --- crates/three-d/src/lib.rs | 8 ++++++-- crates/three-d/src/tests/profile_limits.rs | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 479481c2a..fa22c10fb 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -480,10 +480,10 @@ pub fn revolve_curve_samples<'a>(paths: impl IntoIterator, let (mut anchors, mut contours, mut lines, mut curves, mut caps) = (0usize, 0usize, 0usize, 0usize, 0usize); for sp in paths.into_iter().flat_map(|p| &p.subpaths) { anchors = anchors.saturating_add(sp.anchors.len()); - if anchors > MAX_PROFILE_POINTS { + contours += 1; + if anchors > MAX_PROFILE_POINTS || contours > MAX_PROFILE_POINTS { return Err(TOO_COMPLEX_PROFILE.into()); } - contours += 1; let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; for i in 0..segments { if sp.segment_is_line(i) { @@ -551,6 +551,10 @@ struct HoleSearch { impl HoleSearch { fn new(path: &PathData, rule: FillRule) -> Result { + // Each contour takes a profile point, as in `revolve_curve_samples`. + if path.subpaths.len() > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } let contours: Vec<(BezPath, Rect, Option)> = path .subpaths .iter() diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs index 5eeb10f2a..148cf4fcc 100644 --- a/crates/three-d/src/tests/profile_limits.rs +++ b/crates/three-d/src/tests/profile_limits.rs @@ -68,6 +68,17 @@ fn too_many_anchors_are_refused_as_a_profile() { assert_eq!(refused, TOO_COMPLEX_PROFILE); } +#[test] +fn every_contour_takes_a_point_even_an_empty_one() { + // Contours of one anchor and of none, with no segment between them. + let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(100.0, i as f64))] }, false); + let path = PathData::new((0..=MAX_PROFILE_POINTS).map(lone).collect()); + assert_eq!(revolve_curve_samples([&path], Revolve::default()), Err(TOO_COMPLEX_PROFILE.to_string())); + assert_eq!(hole_segments(&path, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string()), "before any contour is built"); + let given = Revolve { curve_samples: Some(CURVE_SAMPLES), ..Revolve::default() }; + assert_eq!(revolve_mesh(&path, FillRule::NonZero, path.bounds().unwrap(), given).map(|_| ()), Err(TOO_COMPLEX_PROFILE.to_string())); +} + #[test] fn a_profile_at_the_point_limit_is_taken_and_one_past_it_is_refused() { let straight = |anchors: usize| { From 9d01c0e7b5bfeed18e2fbdd37f7d89dc4cf92aff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 19:59:29 -0800 Subject: [PATCH 04/14] MCP: test that list_commands gives input schemas over stdio --- crates/mcp/src/tests/protocol.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/mcp/src/tests/protocol.rs b/crates/mcp/src/tests/protocol.rs index 1fe41c3e5..a3c79aae7 100644 --- a/crates/mcp/src/tests/protocol.rs +++ b/crates/mcp/src/tests/protocol.rs @@ -261,3 +261,20 @@ fn log_records_go_out_as_notifications_once_asked_for() { assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}"); result(&mut s, 4, "logging/setLevel", json!({"level": null})); } + +/// `list_commands` gives each command's input schema over stdin and stdout, as a client reads it. +#[test] +fn list_commands_over_stdio_carries_input_schemas() { + let mut s = server(); + let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_commands", "arguments": {"filter": "view.saved"}}}); + let mut out = Vec::new(); + s.serve((call.to_string() + "\n").as_bytes(), &mut out).unwrap(); + let lines: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let reply = lines.iter().find(|l| l["id"] == 1).cloned().unwrap_or(Value::Null); + assert_eq!(reply["result"]["isError"], false, "{reply}"); + let listed: Value = serde_json::from_str(reply["result"]["content"][0]["text"].as_str().unwrap_or("null")).unwrap_or(Value::Null); + let commands = listed["commands"].as_array().cloned().unwrap_or_default(); + assert!(commands.iter().filter(|c| c.get("inputSchema").is_some()).count() >= 3, "{listed}"); + let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").cloned().unwrap_or(Value::Null); + assert_eq!(saved_list["inputSchema"]["type"], "object", "{listed}"); +} From af2ee68bf3f61ed5a2e75c5eeffe78a79667ca9c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 20:49:13 -0800 Subject: [PATCH 05/14] Revolve: a group's faces keep one back-to-front order when a member is transparent When any member of a revolved group had an opacity below 100% or a blending mode, every member's surface was drawn whole in member order, so the opaque members lost their shared depth sort and a transparent member drew entirely over or under the others. Every member's faces are now sorted into one order. Opaque faces go in that order on their own. A transparent member's faces share one group with its opacity and blending mode, and its scopes group them further, unless a face of another member that overlaps them on the page lies between them in the order; only there is the member split. Faces overlap when the convex hulls of their outlines come within the 0.75 pt seam stroke of each other. Faces that paint nothing never split a member. Past 4,194,304 overlap tests on one group, each further face of such a member is drawn as a part of its own. Docs: docs/revolve.md. --- crates/effects/src/revolve.rs | 330 ++++++++++++++++++++++++++++++---- docs/revolve.md | 5 + 2 files changed, 299 insertions(+), 36 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 99a6e053e..1a491daa5 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{PathData, Rect}; +use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; @@ -18,6 +18,13 @@ pub const REVOLVE: &str = "threeD.revolve"; /// thousands of faces took gigabytes; past this the source path is drawn. const MAX_PAINT_COPIES: usize = 256 << 20; +/// The most face and box tests a group's transparent members' faces take together to rejoin their +/// members' last runs; past them each starts a run of its own. +const MAX_OVERLAP_TESTS: usize = 1 << 22; + +/// The width of the stroke around each face that covers the seams between faces. +const SEAM_WIDTH: f64 = 0.75; + /// About the bytes one copy of `paint` takes. fn paint_bytes(paint: &Paint) -> usize { use std::mem::size_of; @@ -265,7 +272,7 @@ fn surface( .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75); + let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH); // Acute face corners must stay within the overlap width. Miter joins // amplify this seam stroke into visible spikes along the silhouette. edge.join = LineJoin::Round; @@ -431,11 +438,11 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } -/// The members' `surfaces` in order, from the [`Scope`] at `level` of each one's `Profile::scopes` -/// inward: the surfaces of consecutive members that share a scope go in one group with its opacity -/// and blending mode, so each applies once, to the members it holds together. -fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec> { - fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option, level: usize, out: &mut Vec>) { +/// The members' `surfaces`, or runs of their faces, in order, from the [`Scope`] at `level` of each +/// one's `Profile::scopes` inward: consecutive surfaces that share a scope go in one group with its +/// opacity and blending mode, so each applies once, to the faces it holds together. +fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Arc)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option, level: usize, out: &mut Vec>) { if let Some(scope) = scope { let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); group.opacity = scope.opacity; @@ -455,15 +462,104 @@ fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec, +} + +impl Coverage { + /// `None` for a face with no outline and no box. + fn of(face: &Node) -> Option { + let points: Vec = match face.path_data() { + Some(path) => path + .to_bezpath() + .elements() + .iter() + .flat_map(|e| match *e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![p], + PathEl::QuadTo(a, b) => vec![a, b], + PathEl::CurveTo(a, b, c) => vec![a, b, c], + PathEl::ClosePath => vec![], + }) + .collect(), + None => { + let b = face.visual_bounds()?; + vec![Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)] + } + }; + let hull = convex_hull(points); + let first = *hull.first()?; + let reach = SEAM_WIDTH / 2.0; + let bounds = hull.iter().fold(Rect::from_points(first, first), |b, p| b.union_pt(*p)).inflate(reach, reach); + Some(Coverage { bounds, hull }) + } + + /// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the + /// separating axes of their edges and of the page's axes: `true` whenever they overlap, and + /// sometimes near a corner when they don't. + fn meets(&self, other: &Coverage) -> bool { + fn normals(hull: &[Point]) -> impl Iterator + '_ { + (0..hull.len()).filter_map(|i| { + let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?); + Some(Vec2::new(a.y - b.y, b.x - a.x)) + }) + } + let axes = [Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)].into_iter().chain(normals(&self.hull)).chain(normals(&other.hull)); + let span = |hull: &[Point], axis: Vec2| { + hull.iter().map(|p| p.to_vec2().dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))) + }; + axes.filter(|axis| axis.hypot() > 0.0).all(|axis| { + let ((a0, a1), (b0, b1)) = (span(&self.hull, axis), span(&other.hull, axis)); + let gap = SEAM_WIDTH * axis.hypot(); + b0 - a1 < gap && a0 - b1 < gap + }) + } +} + +/// The convex hull of `points`, counterclockwise in a y-up frame, without repeated points. +fn convex_hull(mut points: Vec) -> Vec { + points.retain(|p| p.is_finite()); + points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + points.dedup(); + if points.len() < 3 { + return points; + } + // One side of the hull from the first point to the last, without the last. + fn side<'a>(points: impl Iterator) -> Vec { + let mut chain: Vec = vec![]; + for &p in points { + while let [.., o, a] = chain[..] + && (a - o).cross(p - o) <= 0.0 + { + chain.pop(); + } + chain.push(p); + } + chain.pop(); + chain + } + let mut hull = side(points.iter()); + hull.extend(side(points.iter().rev())); + hull +} + +/// Do boxes `a` and `b` overlap with some area? +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + /// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, -/// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when +/// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. fn group_art(n: &Node, expand: bool) -> Option { let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; @@ -490,18 +586,86 @@ fn group_art(n: &Node, expand: bool) -> Option { return None; } let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); - let children = if opaque && plain { + let children = if !plain { + scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0) + } else { + let count = surfaces.len(); let mut faces = vec![]; - for (depths, s, _) in surfaces { + for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() { + let (opacity, blend) = (s.opacity, s.blend); if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children)); + faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face))); } } // A stable sort: faces at the same depth keep the members' order. faces.sort_by(|a, b| a.0.total_cmp(&b.0)); - vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] - } else { - scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, s)).collect(), 0) + if opaque { + vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))] + } else { + // An opaque member's face goes in the order on its own. The faces of a member with + // transparency share a run, one group with the opacity and blending mode of its fill or + // stroke, and its scopes group runs further. Such a face joins its member's last run + // unless a face drawn after that run overlaps it: only then does the member split, + // where one group can't be both behind and in front of another face. + struct Run<'a> { + member: Option, + scopes: &'a [Scope], + bounds: Rect, + faces: Vec>, + cover: Vec, + opacity: f32, + blend: vectorcraft_doc::color::BlendMode, + } + let mut runs: Vec = vec![]; + let mut last_run = vec![None; count]; + // Past this many tests a face starts a run of its own, which never draws out of order. + let mut budget = MAX_OVERLAP_TESTS; + for (_, member, opacity, blend, scopes, face) in faces { + let paints = opacity > 0.0 && scopes.iter().all(|s| s.opacity > 0.0); + let Some(cover) = Coverage::of(&face).filter(|_| paints) else { + // A face that paints nothing goes in the order on its own and overlaps nothing. + runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend }); + continue; + }; + if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() { + runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); + continue; + } + let mut clear = |r: &Run| { + budget = budget.saturating_sub(1); + if !overlaps(r.bounds, cover.bounds) { + return budget > 0; + } + budget = budget.saturating_sub(r.cover.len()); + budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover)) + }; + let joins = + last_run.get(member).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear))); + match joins.and_then(|i| runs.get_mut(i)) { + Some(run) => { + run.bounds = run.bounds.union(cover.bounds); + run.faces.push(face); + run.cover.push(cover); + } + None => { + if let Some(last) = last_run.get_mut(member) { + *last = Some(runs.len()); + } + runs.push(Run { member: Some(member), scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); + } + } + } + let runs = runs.into_iter().filter_map(|mut r| { + if r.member.is_none() { + return r.faces.pop().map(|face| (r.scopes, face)); + } + let mut group = Node::group(n.id, r.faces); + group.opacity = r.opacity; + group.blend = r.blend; + Some((r.scopes, Arc::new(group))) + }); + scoped(n.id, runs.collect(), 0) + } }; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -712,18 +876,76 @@ mod tests { } #[test] - fn opaque_members_share_one_back_to_front_order_and_others_keep_their_own_surfaces() { + fn opaque_members_share_one_back_to_front_order() { let g = group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)]); let art = revolve_art(&g).unwrap(); assert_eq!(art.children().unwrap().len(), 1, "one order for every face"); assert!(art.children().unwrap()[0].children().unwrap().len() > 16); - let mut faded = (*member(2, 50.0)).clone(); + } + + /// The opacities and blending modes of the groups a face is drawn in, outermost first, leaving + /// out groups with neither. + type Chain = Vec<(f32, vectorcraft_doc::color::BlendMode)>; + + /// The faces of `art` in drawing order, each with its [`Chain`]. + fn faces_in_order(art: &Node) -> Vec<(Node, Chain)> { + fn visit(n: &Node, mut scopes: Chain, out: &mut Vec<(Node, Chain)>) { + match n.children() { + Some(children) => { + if n.opacity != 1.0 || n.blend != Default::default() { + scopes.push((n.opacity, n.blend)); + } + for c in children { + visit(c, scopes.clone(), out); + } + } + None => out.push((n.clone(), scopes)), + } + } + let mut out = vec![]; + visit(art, vec![], &mut out); + out + } + + #[test] + fn a_transparent_member_leaves_every_face_in_one_back_to_front_order() { + let faces_of = |members| faces_in_order(&revolve_art(&group_with(json!({"segments":16}), members)).unwrap()); + let alone = faces_of(vec![member(1, 0.0), member(2, 50.0)]); + let mut faded = (*member(3, 20.0)).clone(); faded.opacity = 0.5; - let g = group_with(json!({"segments":16}), vec![member(1, 0.0), Arc::new(faded)]); - let art = revolve_art(&g).unwrap(); - let surfaces = art.children().unwrap(); - assert_eq!(surfaces.len(), 2, "a transparent member stays a surface of its own"); - assert_eq!(surfaces[1].opacity, 0.5); + let faces = faces_of(vec![member(1, 0.0), member(2, 50.0), Arc::new(faded)]); + let opaque: Vec<_> = faces.iter().filter(|(_, s)| s.is_empty()).map(|(f, _)| f).collect(); + assert_eq!(opaque, alone.iter().map(|(f, _)| f).collect::>(), "the opaque members' faces keep their shared order"); + assert!(faces.iter().all(|(_, s)| s.is_empty() || s == &[(0.5, Default::default())]), "the member's opacity applies once to each face"); + let first = faces.iter().position(|(_, s)| !s.is_empty()).unwrap(); + let last = faces.iter().rposition(|(_, s)| !s.is_empty()).unwrap(); + assert!(first > 0 && last + 1 < faces.len(), "the ring between the others draws in front of the back ones and behind the front ones"); + let groups = |art: &Node| { + let mut count = 0; + art.walk(&mut |n| count += usize::from(n.children().is_some() && n.opacity == 0.5)); + count + }; + // Rings one above the other: their faces alternate in the order but none overlaps the + // other ring, so each ring's opacity applies once, to all its faces. + let faded = |id, y: f64| { + let mut n = (*member(id, 40.0)).clone(); + n.opacity = 0.5; + if let NodeKind::Path { path, .. } = &mut n.kind { + *path = PathData::from_bezpath(&(Affine::translate((0.0, y)) * path.to_bezpath())); + } + Arc::new(n) + }; + let art = revolve_art(&group_with(json!({"segments":16}), vec![faded(1, 0.0), faded(2, 200.0)])).unwrap(); + assert!(faces_in_order(&art).iter().all(|(_, s)| s == &[(0.5, Default::default())])); + assert_eq!(groups(&art), 2, "one group for each ring"); + // A tall cylinder inside both that paints nothing splits neither. + let mut hidden = (*member(3, 0.0)).clone(); + hidden.opacity = 0.0; + if let NodeKind::Path { path, .. } = &mut hidden.kind { + *path = PathData::from_bezpath(&(Affine::translate((0.0, -20.0)) * Affine::scale_non_uniform(1.0, 7.0) * path.to_bezpath())); + } + let art = revolve_art(&group_with(json!({"segments":16}), vec![faded(1, 0.0), faded(2, 200.0), Arc::new(hidden)])).unwrap(); + assert_eq!(groups(&art), 2, "one group for each faded ring"); } fn star(id: u64) -> Arc { @@ -741,6 +963,33 @@ mod tests { )) } + #[test] + fn faces_overlap_by_their_painted_shapes_and_seams_not_only_their_boxes() { + let face = |points: &[(f64, f64)]| { + let mut b = BezPath::new(); + b.move_to(points[0]); + for &p in &points[1..] { + b.line_to(p); + } + b.close_path(); + Coverage::of(&Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::default())).unwrap() + }; + let a = face(&[(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)]); + assert_eq!(a.hull.len(), 3); + assert_eq!( + face(&[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.0, 5.0), (0.0, 10.0)]).hull.len(), + 4, + "a point on an edge or inside is left out" + ); + // Across the diagonal 2 / sqrt(2) apart: their boxes overlap, their shapes and seams don't. + let b = face(&[(10.0, 10.0), (10.0, 2.0), (2.0, 10.0)]); + assert!(overlaps(a.bounds, b.bounds) && !a.meets(&b) && !b.meets(&a)); + // 1 / sqrt(2) apart, within the two half seam strokes. + let c = face(&[(10.0, 10.0), (10.0, 1.0), (1.0, 10.0)]); + assert!(a.meets(&c) && c.meets(&a)); + assert!(a.meets(&a), "a face meets itself"); + } + #[test] fn a_groups_members_share_revolves_limits() { let alone = group_with(json!({}), vec![star(1)]); @@ -763,28 +1012,37 @@ mod tests { let screened = blend(Arc::new(Node::group(NodeId(5), vec![member(3, 40.0), inner])), BlendMode::Screen); let group = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply), member(2, 20.0), screened]); let art = revolve_art(&group).expect("revolved"); - let modes = |n: &Node| match &n.kind { - NodeKind::Group { children, .. } => children.iter().map(|c| c.blend).collect::>(), - _ => panic!("a group"), + let faces = faces_in_order(&art); + let modes = |s: &[(f32, BlendMode)]| s.iter().map(|(_, b)| *b).collect::>(); + let chains = [vec![BlendMode::Multiply], vec![], vec![BlendMode::Screen], vec![BlendMode::Screen, BlendMode::Multiply]]; + for chain in &chains { + assert!(faces.iter().any(|(_, s)| &modes(s) == chain), "faces drawn in {chain:?}"); + } + assert!(faces.iter().all(|(_, s)| chains.contains(&modes(s))), "each mode applies once, to the member or group that has it"); + // Faces of the screened group's members that come together in the drawing order share one Screen group. + let runs = |faces: &[(Node, Chain)], inside: &dyn Fn(&Chain) -> bool| { + faces.windows(2).filter(|w| inside(&w[1].1) && !inside(&w[0].1)).count() + usize::from(faces.first().is_some_and(|f| inside(&f.1))) }; - assert_eq!(modes(&art), [BlendMode::Multiply, BlendMode::Normal, BlendMode::Screen], "a surface or group for each member"); - let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; - let screened = &children[2]; - assert_eq!(modes(screened), [BlendMode::Normal, BlendMode::Multiply], "the screened group's members, together, in their own modes"); - let NodeKind::Group { children: inner, .. } = &screened.kind else { panic!("a group") }; - assert_eq!(modes(&inner[1]).len(), 1, "the multiplied member's surface"); + let groups = |art: &Node, pick: &dyn Fn(&Node) -> bool| { + let mut count = 0; + art.walk(&mut |n| count += usize::from(n.children().is_some() && pick(n))); + count + }; + let screened = |s: &Chain| s.first().is_some_and(|(_, b)| *b == BlendMode::Screen); + assert_eq!(groups(&art, &|n| n.blend == BlendMode::Screen), runs(&faces, &screened)); let plain = group_with(json!({}), vec![member(1, 0.0)]); assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed"); let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]); assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face"); - // A nested group's opacity applies once, to its members' surfaces together. + // A nested group's opacity applies once, to its members' faces that come together. let mut faded = Node::group(NodeId(6), vec![member(3, 40.0), member(4, 45.0)]); faded.opacity = 0.5; let art = revolve_art(&group_with(json!({}), vec![member(1, 0.0), Arc::new(faded)])).expect("revolved"); - let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; - let opacities = |n: &Node| n.children().unwrap().iter().map(|c| c.opacity).collect::>(); - assert_eq!(children.iter().map(|c| c.opacity).collect::>(), [1.0, 0.5]); - assert_eq!(opacities(&children[1]), [1.0, 1.0], "the faded group's members, together"); + let faces = faces_in_order(&art); + assert!(faces.iter().all(|(_, s)| s.is_empty() || s == &[(0.5, BlendMode::Normal)]), "the faded group's opacity applies once"); + let half = |s: &Chain| !s.is_empty(); + assert!(faces.iter().any(|(_, s)| half(s)) && faces.iter().any(|(_, s)| !half(s))); + assert_eq!(groups(&art, &|n| n.opacity == 0.5), runs(&faces, &half), "the faded group's members, together"); } #[test] diff --git a/docs/revolve.md b/docs/revolve.md index fd0f36f16..6ecd4957e 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -6,6 +6,11 @@ Select a path, compound path, type, or a group or layer of them, then choose **E On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. +The faces of all members draw in one back-to-front order. The opacity and blending mode of a member, or of a group +inside the group, apply once to all its faces. Where a face of another member lies between its faces in that order +and overlaps them on the page, its faces are split there, and the opacity and blending mode apply once to each part. +Two faces overlap when the convex hulls of their outlines come within the 0.75 pt seam stroke of each other. After +4,194,304 overlap tests on one group, each further face of such a member is drawn as a part of its own. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. From e33906976d91ad615625831633d304264a0e2aa7 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 10:00:03 +0500 Subject: [PATCH 06/14] Spelling right-click: only the topmost type's word; dictionary tests one at a time (part of #4936) --- crates/engine/src/tests/spelling.rs | 17 ++++++++ crates/ui-egui/src/canvas/tool_overlays.rs | 49 ++++++++++++---------- crates/ui-egui/src/tests/contextmenu.rs | 22 ++++++++++ 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs index 0dfc9fab5..eca38d8e6 100644 --- a/crates/engine/src/tests/spelling.rs +++ b/crates/engine/src/tests/spelling.rs @@ -6,6 +6,14 @@ use serde_json::{Value, json}; use crate::cmd::spelling::MAX_DICTIONARY_FILE_BYTES; use crate::*; +/// Held by each test that changes the custom dictionary, which the whole process shares: a save +/// writes every word in it to the test's file and a reload takes the file's words back, so another +/// test's word could come back after that test deleted it. +fn dictionary_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// A session with a new 400 × 400 document. fn session() -> Session { let mut s = Session::new(); @@ -115,6 +123,7 @@ fn marked(s: &mut Session) -> Vec { #[test] fn added_words_are_right_in_every_check_until_deleted() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); text(&mut s, 50.0, "drawing Zqvwkx tools"); assert_eq!(marked(&mut s), ["Zqvwkx"]); @@ -137,6 +146,7 @@ fn added_words_are_right_in_every_check_until_deleted() { #[test] fn a_changed_word_replaces_the_old_one() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); text(&mut s, 50.0, "xkvzqa xkvzqb"); run(&mut s, "text.spelling.addWord", json!({"word": "xkvzqa"})); @@ -150,6 +160,7 @@ fn a_changed_word_replaces_the_old_one() { #[test] fn the_custom_dictionary_file_is_read_and_kept_up_to_date() { + let _one_at_a_time = dictionary_lock(); let dir = vectorcraft_testkit::temp_dir("spelling-dictionary"); let file = dir.join("Custom Dictionary.txt"); std::fs::write(&file, "kqzvwx\nnot one word\n\n").unwrap(); @@ -170,6 +181,7 @@ fn the_custom_dictionary_file_is_read_and_kept_up_to_date() { #[test] fn ignore_all_skips_a_word_in_the_active_document_only() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); text(&mut s, 50.0, "Wqzxkv drawing wqzxkv vqzxkw"); run(&mut s, "text.spelling.ignoreAll", json!({"word": "WQZXKV"})); @@ -183,6 +195,7 @@ fn ignore_all_skips_a_word_in_the_active_document_only() { #[test] fn custom_dictionary_commands_refuse_what_isnt_one_word() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); let long = "q".repeat(65); for (id, p) in [ @@ -210,6 +223,7 @@ fn in_dictionary(s: &mut Session, word: &str) -> bool { #[test] fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() { + let _one_at_a_time = dictionary_lock(); let dir = vectorcraft_testkit::temp_dir("spelling-unreadable"); // A folder where the file should be can't be read as one; a file past what craft-proof reads // would lose its other words if written back. @@ -231,6 +245,7 @@ fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() { #[test] fn a_change_that_cant_be_saved_is_taken_back() { + let _one_at_a_time = dictionary_lock(); let mut s = session(); run(&mut s, "text.spelling.addWord", json!({"word": "wkqzvx"})); let dir = vectorcraft_testkit::temp_dir("spelling-rollback"); @@ -248,6 +263,7 @@ fn a_change_that_cant_be_saved_is_taken_back() { #[cfg(unix)] #[test] fn a_word_another_window_saved_stays_when_this_save_fails() { + let _one_at_a_time = dictionary_lock(); use std::os::unix::fs::PermissionsExt; let dir = vectorcraft_testkit::temp_dir("spelling-readonly"); let file = dir.join("Custom Dictionary.txt"); @@ -296,6 +312,7 @@ fn misspellings_of_one_object_are_what_the_check_lists() { /// what Auto Spell Check found before is checked again. #[test] fn custom_dictionary_changes_move_its_generation_on() { + let _one_at_a_time = dictionary_lock(); use crate::cmd::spelling::dictionary_generation; let mut s = session(); let mut last = dictionary_generation(); diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index 5e23522f7..0151b3558 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -166,41 +166,48 @@ fn auto_spell_checks(n: &Node) -> bool { !shown(&n.appearance.effects) && !n.appearance.items.iter().any(|i| shown(i.effects())) } -/// The word with Auto Spell Check's red line at `p` (document space), on top of the others: in -/// the topmost visible, unlocked type object whose unknown word's glyphs `p` is on, with what its -/// language's dictionary suggests instead. For the context menu's suggestions, Ignore All and Add +/// The word with Auto Spell Check's red line at `p` (document space): in the topmost visible type +/// object whose glyphs `p` is on, when that object isn't locked and Auto Spell Check marks it and +/// its word at `p` is one it underlines, with what its language's dictionary suggests instead. For the context menu's suggestions, Ignore All and Add /// to Dictionary ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)). pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option { let st = app.session.active()?; - let mut hit = None; - fn walk(nodes: &[std::sync::Arc], f: &mut impl FnMut(&Node)) { - for n in nodes.iter().filter(|n| n.visible && !n.locked) { - f(n); + // The topmost type whose glyphs `p` is on, and whether it or a layer or group holding it is + // locked: a word under other type isn't the one right-clicked, whether that type's word there + // is right or not. + let mut top: Option<(&Node, bool)> = None; + fn walk<'a>(nodes: &'a [std::sync::Arc], locked: bool, f: &mut impl FnMut(&'a Node, bool)) { + for n in nodes.iter().filter(|n| n.visible) { + let locked = locked || n.locked; + f(n, locked); if let Some(children) = n.children() { - walk(children, f); + walk(children, locked, f); } } } // Paint order: a later hit is on top. - walk(&st.doc.layers, &mut |n| { + walk(&st.doc.layers, false, &mut |n, locked| { let NodeKind::Text(tx) = &n.kind else { return }; - if !auto_spell_checks(n) || n.geometric_bounds().is_none_or(|b| !b.contains(p)) { - return; - } - let (words, _) = vectorcraft_engine::cmd::spelling::misspellings(tx, &st.spelling_ignored, MAX_UNDERLINED); - if words.is_empty() { + if n.geometric_bounds().is_none_or(|b| !b.contains(p)) { return; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); - for w in words { - let on = vectorcraft_text::selection_quads(&lay, w.range.start, w.range.end).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)); - if on { - hit = Some((n.id, w)); - } + let on = vectorcraft_text::selection_quads(&lay, 0, tx.plain_text().len()).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)); + if on { + top = Some((n, locked)); } }); - let (id, w) = hit?; - let Some(NodeKind::Text(tx)) = st.doc.node(id).map(|n| &n.kind) else { return None }; + let (n, locked) = top?; + let NodeKind::Text(tx) = &n.kind else { return None }; + if locked || !auto_spell_checks(n) { + return None; + } + let (words, _) = vectorcraft_engine::cmd::spelling::misspellings(tx, &st.spelling_ignored, MAX_UNDERLINED); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); + let w = words + .into_iter() + .find(|w| vectorcraft_text::selection_quads(&lay, w.range.start, w.range.end).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)))?; + let id = n.id; let word = tx.plain_text().get(w.range.clone())?.to_string(); let uid = st.uid; let suggestions = app diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index 0812f410e..830da939d 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -506,3 +506,25 @@ fn no_spelling_items_off_a_misspelled_word_or_with_auto_spell_check_off() { let texts = click(&mut app, &ctx, p, PointerButton::Secondary); assert!(!has(&texts, "Ignore All"), "off: {texts:?}"); } + +/// A right word in type on top of an underlined word in other type: the right-click is on the +/// top type, so it lists no spelling items for the word under it. +#[test] +fn a_word_under_other_type_is_not_the_one_right_clicked() { + let mut app = app(); + let under = spelled(&mut app, "Qzvxtrwk"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = word_at(&app, under, 0, 8); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(has(&texts, "Ignore All"), "alone, the word is offered: {texts:?}"); + frame( + &mut app, + &ctx, + vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + app.run("text.create", json!({"x": 20, "y": 60, "text": "the cat sat on the mat", "size": 24})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "under right type: {texts:?}"); +} From a1949095c61e21d2ea73eaca73a5b598eced6d98 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 21:07:55 -0800 Subject: [PATCH 07/14] Type: an option fits a text object's bounds to its glyph outlines Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default) is in Preferences > Type > Glyph Bounds and the Transform panel menu. With it on, the bounding box, the Transform, Control bar and Properties X, Y, W and H, `object.setBounds`, the transform tools' reference point and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size. - The engine caches each type object's ink in text space with its layout bounds; the cache is not saved. - Use Preview Bounds adds the characters' strokes, and type with geometry effects is measured as drawn. Skewed type is measured by its outlines. - Type that draws nothing, area type, type on a path and type inside a selected group are measured as before. Align and Distribute keep following Align to Glyph Bounds. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md, object.setBounds params text. --- Cargo.lock | 1 + crates/doc/src/blend.rs | 1 + crates/doc/src/document_guides.rs | 1 + crates/doc/src/inline.rs | 1 + crates/doc/src/live/envelope.rs | 1 + crates/doc/src/text.rs | 5 + crates/doc/src/text/object.rs | 8 + crates/effects/src/preview.rs | 107 +++++++ crates/engine/Cargo.toml | 2 + .../engine/src/cmd/addons/cleanup/checks.rs | 1 + crates/engine/src/cmd/object/align.rs | 17 +- crates/engine/src/cmd/object/bounds.rs | 35 ++- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/object/transform.rs | 2 +- crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/cmd/textedit.rs | 2 + crates/engine/src/cmd/typecmd.rs | 2 + crates/engine/src/prefs.rs | 6 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/typeglyphbounds.rs | 297 ++++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/context.rs | 8 + crates/tools/src/direct.rs | 4 +- crates/tools/src/lib.rs | 1 + crates/tools/src/select.rs | 15 +- crates/tools/src/select/tests.rs | 24 ++ crates/tools/src/xform/transform.rs | 2 +- crates/ui-egui/src/app/view.rs | 5 +- crates/ui-egui/src/caches.rs | 5 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/transform.rs | 5 + crates/ui-egui/src/panels/transform/tests.rs | 20 ++ docs/control-protocol.md | 4 + docs/mcp.md | 23 ++ docs/target-app-parity.md | 1 + 61 files changed, 632 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/tests/typeglyphbounds.rs diff --git a/Cargo.lock b/Cargo.lock index d82f8255e..88a9abca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6499,6 +6499,7 @@ dependencies = [ "ruzstd", "serde", "serde_json", + "skrifa 0.47.0", "thiserror 2.0.21", "vectorcraft-affinity", "vectorcraft-apischema", diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 5ea0ff9bd..d6b7eda2c 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -750,6 +750,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64 if resized { o.cached_bounds = None; o.cached_baselines.clear(); + o.cached_ink = None; } } diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index f4a037f36..510de3a4d 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -347,6 +347,7 @@ impl Document { t.first_para_continues = false; t.cached_bounds = None; t.cached_baselines.clear(); + t.cached_ink = None; } let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs index 3ab3eb655..c5bf1972d 100644 --- a/crates/doc/src/inline.rs +++ b/crates/doc/src/inline.rs @@ -71,6 +71,7 @@ impl Document { } if changed { t.cached_bounds = None; + t.cached_ink = None; } changed } diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 3fdddd680..8f9b19704 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -236,6 +236,7 @@ impl TypeInks { text.xf = Affine::new([a, b, c, d, 0.0, 0.0]); text.cached_bounds = None; text.cached_baselines.clear(); + text.cached_ink = None; let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs)); match content.and_then(|key| self.content.get(&key)) { Some(ink) => { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 249b46d55..2d1a60e59 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -121,6 +121,11 @@ pub struct TextObject { /// vertical column's centre line), filled in with `cached_bounds` (not serialized). #[serde(skip)] pub cached_baselines: Vec<(Point, Point)>, + /// Cached ink of the glyphs in text space (`TextLayout::ink_bounds`, `None` when nothing draws), + /// with the `cached_bounds` it was measured with: filled in with `cached_bounds` by the engine's + /// text edits, and stale once `cached_bounds` differs ([`Self::ink`]). Not serialized. + #[serde(skip)] + pub cached_ink: Option<(Rect, Option)>, } /// Conversion from stored character attributes to document-space type controls. diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index dc83c33cc..12bc7ec89 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -71,6 +71,7 @@ impl TextObject { self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); self.cached_bounds = None; self.cached_baselines.clear(); + self.cached_ink = None; true } @@ -97,6 +98,7 @@ impl TextObject { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, } } /// The list counts the story's earlier frames carry into this frame at each level @@ -288,6 +290,12 @@ impl TextObject { TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), } } + /// The cached ink of the glyphs in text space ([`Self::cached_ink`]): `Some(None)` when nothing + /// draws, and `None` when it was not measured with the current `cached_bounds`. + pub fn ink(&self) -> Option> { + let (bounds, ink) = self.cached_ink?; + (self.cached_bounds == Some(bounds)).then_some(ink) + } /// How far the characters' strokes paint past their glyphs, in document space: they scale /// with the type. pub fn char_stroke_outset(&self) -> f64 { diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index 5a619f8ac..ee8d4e1c1 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -17,6 +17,8 @@ //! [`Selection::anchor_bounds`] measure the objects themselves. Raster effects (shadows, glows, //! blurs) are not measured. In one object, the art of at most 4,096 symbol instances is measured //! with its geometry effects, and the other instances in it are measured without them. +//! [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure as these do and, +//! with [`Measure::glyphs`], take point type by the ink of its glyphs (Measure Type by Glyph Bounds). use std::cell::{Cell, RefCell}; use std::collections::HashMap; @@ -74,6 +76,111 @@ pub fn anchor_bounds(selection: &Selection, doc: &Document, preview: bool) -> Op union_opt(bounds_of(doc, &selection.whole(), preview), selection.anchors_box(doc)) } +/// How [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure objects: +/// `preview` as in [`bounds_of`], and with `glyphs` (Preferences › Type › Measure Type by Glyph +/// Bounds), point type, horizontal or vertical, by the ink of its glyphs instead of its em box +/// ([`text_ink_in`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Measure { + pub preview: bool, + pub glyphs: bool, +} + +impl Measure { + /// See [`by_glyphs`]. + fn by_glyphs(self, drawn: &Drawn, n: &Node) -> bool { + self.glyphs + && matches!(&n.kind, NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Point)) + && n.projection().is_none() + && !(self.preview && drawn.draws_geometry(n)) + } +} + +/// Whether `m` measures `n` by the ink of its glyphs: point type, not in perspective, and with +/// `preview` only when no geometry effect changes what it draws. +pub fn by_glyphs(symbols: &SymbolBoxes, n: &Node, m: Measure) -> bool { + m.by_glyphs(&Drawn::new(symbols), n) +} + +/// [`bounds_of`] measured by `m`. +pub fn bounds_of_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option { + if !m.glyphs { + return bounds_of(doc, ids, m.preview); + } + let symbols = doc.symbol_boxes(); + let drawn = Drawn::new(&symbols); + if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) { + return bounds_of(doc, ids, m.preview); + } + ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, Affine::IDENTITY, m))) +} + +/// [`oriented_bounds`] measured by `m`. +pub fn oriented_bounds_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option { + if !m.glyphs { + return oriented_bounds(doc, ids, m.preview); + } + let symbols = doc.symbol_boxes(); + let drawn = Drawn::new(&symbols); + if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) { + return oriented_bounds(doc, ids, m.preview); + } + let angle = doc.bbox_angle(ids); + let to_local = if angle == 0.0 { Affine::IDENTITY } else { OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse() }; + let rect = ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, to_local, m)))?; + Some(OrientedBox { angle, rect }) +} + +/// [`anchor_bounds`] with the objects selected whole measured by `m`. +pub fn anchor_bounds_with(selection: &Selection, doc: &Document, m: Measure) -> Option { + if selection.anchors.is_empty() { + return None; + } + union_opt(bounds_of_with(doc, &selection.whole(), m), selection.anchors_box(doc)) +} + +/// The box of `n` as `a` would place it, without changing it, measured by `m`: [`bounds_in`] with +/// `preview`, else [`Node::bounds_in`] of its geometric bounds, and point type by its glyphs as +/// [`Measure`] says. +pub fn bounds_in_with(symbols: &SymbolBoxes, n: &Node, a: Affine, m: Measure) -> Option { + measured_in(&Drawn::new(symbols), n, a, m) +} + +/// See [`bounds_in_with`]. +fn measured_in(drawn: &Drawn, n: &Node, a: Affine, m: Measure) -> Option { + if m.by_glyphs(drawn, n) + && let NodeKind::Text(t) = &n.kind + && let Some(r) = text_ink_in(t, a) + { + // The fills' and characters' strokes paint around the glyphs, as in `Node::visual_bounds`. + let o = if m.preview { n.appearance.outset().max(t.char_stroke_outset()) } else { 0.0 }; + return Some(r.inflate(o, o)); + } + match (m.preview, a == Affine::IDENTITY) { + (true, _) => drawn.next().bounds_in(n, a), + (false, true) => n.geometric_bounds_with(drawn.symbols), + (false, false) => n.bounds_in(drawn.symbols, a, false), + } +} + +/// The box of the ink of type `t`'s glyphs with its text space mapped by `a * t.xf`: the cached +/// ink ([`vectorcraft_doc::TextObject::ink`]) when that map keeps a rectangle square, else the +/// glyph outlines mapped point by point ([`vectorcraft_text::TextLayout::ink_bounds_in`]). `None` +/// when nothing draws (no text, only spaces, or glyphs without outlines). +pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option { + let m = a * t.xf; + let [p, q, r, s, _, _] = m.as_coeffs(); + let square = (q.abs() <= 1e-12 && r.abs() <= 1e-12) || (p.abs() <= 1e-12 && s.abs() <= 1e-12); + if square { + let ink = match t.ink() { + Some(ink) => ink, + None => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds(), + }; + return ink.map(|i| m.transform_rect_bbox(i)); + } + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds_in(m) +} + /// The visual bounds of `n` as drawn: those of its [`drawn_art`], else /// [`Node::visual_bounds_with`]. pub fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 10bb22599..ef403dc80 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -88,3 +88,5 @@ zune-jpegxl = { workspace = true } zune-core = { workspace = true } # The JavaScript parser's lexer, compared with the script checker's on the same scripts. vectorcraft-js = { workspace = true } +# A bundled font's own glyph boxes, for the glyph bounds tests. +skrifa = { workspace = true } diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 4b03f0e43..dfb450fae 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -275,6 +275,7 @@ fn same_art(a: &NodeKind, b: &NodeKind) -> bool { moved.xf = a.xf; moved.cached_bounds = a.cached_bounds; moved.cached_baselines.clone_from(&a.cached_baselines); + moved.cached_ink = a.cached_ink; **a == moved } } diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 82a61b02a..b32275d1b 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -6,16 +6,23 @@ use super::{ use vectorcraft_doc::{NodeKind, TextKind}; impl Session { - /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: - /// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included, - /// see [`effects::preview`]), else geometric bounds. + /// The bounds of `ids` the Transform panel and the bounding box measure: with Use Preview + /// Bounds on, visual bounds as drawn (strokes and geometry effects included, see + /// [`effects::preview`]), else geometric bounds; with Measure Type by Glyph Bounds on, point + /// type by the ink of its glyphs ([`Session::bounds_measure`]). pub fn transform_bounds(&self, ids: &[NodeId]) -> Option { - effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) + effects::preview::bounds_of_with(&self.doc().ok()?.doc, ids, self.bounds_measure()) } /// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding /// box, see [`Document::oriented_bounds`]). pub fn transform_box(&self, ids: &[NodeId]) -> Option { - effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) + effects::preview::oriented_bounds_with(&self.doc().ok()?.doc, ids, self.bounds_measure()) + } + /// How the bounding box, the Transform panel and `object.setBounds` measure objects: Use + /// Preview Bounds, and point type by its glyphs with Measure Type by Glyph Bounds + /// (`typeGlyphBounds`). + pub fn bounds_measure(&self) -> effects::preview::Measure { + effects::preview::Measure { preview: self.prefs.use_preview_bounds, glyphs: self.prefs.type_glyph_bounds } } } diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index df2c1ffd1..3dc1c37da 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -59,24 +59,31 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor let st = s.doc()?; check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?; check_switches(p, &["proportional"], "object.setBounds")?; + let how = s.bounds_measure(); if anchors && transforms_objects(p) && !bool_or(p, "copy", false) - && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) + && let Some(b) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, how) { return Ok(Bounds::Anchors(ids, b)); } let d = &s.doc()?.doc; // Sizes are measured square to the bounding box, which a rotation turns with the objects: - // the math below runs in its frame. - let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + // the math below runs in its frame. With Measure Type by Glyph Bounds, point type is measured + // by its glyphs' ink, which a transform of the type moves and scales with it. + let geometric = effects::preview::Measure { preview: false, ..how }; + let frame = effects::preview::oriented_bounds_with(d, &ids, geometric).ok_or_else(|| EngineError::Other("no bounds".into()))?; let g = frame.rect; + let symbols = d.symbol_boxes(); + let by_glyphs = how.glyphs && ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::by_glyphs(&symbols, n, how)); // With Use Preview Bounds the values measure the visual box as drawn, whose margins around the // geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of // `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their - // weight when an instance scales, so the rest of the margins stay. + // weight when an instance scales, so the rest of the margins stay. Type measured by its glyphs + // is measured as drawn after each scale tried instead (below). let (v, vs) = if s.prefs.use_preview_bounds { - (effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g)) + let v = effects::preview::oriented_bounds_with(d, &ids, how).map_or(g, |b| b.rect); + (v, if by_glyphs { v } else { scaling_box(d, &ids, &frame).unwrap_or(g) }) } else { (g, g) }; @@ -125,16 +132,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor // The scales and the point above can miss the size or the reference point when the margins of // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one - // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and + // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, // objects with a butt-capped stroke along a segment at a slant, whose margins change with the - // segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]). - let symbols = d.symbol_boxes(); + // segment's direction, and type measured by its glyphs, are measured as drawn after each scale + // tried instead ([`refit_drawn`]). let to_local = frame.to_doc().inverse(); let drawn = || { - ids.iter() - .filter_map(|id| d.node(*id)) - .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + by_glyphs + || ids + .iter() + .filter_map(|id| d.node(*id)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) }; + // Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink + // included), so the scales above are exact and nothing is refitted. let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { None } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { @@ -145,7 +156,7 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| { let mut n = n.clone(); n.transform(xf, sc); - vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local)) + vectorcraft_geom::union_opt(acc, effects::preview::bounds_in_with(&symbols, &n, to_local, how)) }) }; // The scales above keep the effects' reach as a margin that stays, and give 0 for a size diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index a923ab2eb..086bf3843 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -287,7 +287,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, point type selected on its own is measured by its glyphs' ink, which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index ae45f4d97..618486097 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -12,7 +12,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { // Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre. let st = s.doc()?; if !bool_or(p, "copy", false) - && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) + && let Some(b) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()) { return Ok(b.center()); } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f3cf6416f..dde754843 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -434,6 +434,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // The Align panel menu's Align to Glyph Bounds: Align and Distribute measure type by its glyphs' ink. p!("alignGlyphBoundsPoint", "Type", "Align to Glyph Bounds", "Point Text", bool), p!("alignGlyphBoundsArea", "Type", "Align to Glyph Bounds", "Area Text", bool), + // The bounding box and the Transform panel measure point type by its glyphs' ink (also in the Transform panel menu). + p!("typeGlyphBounds", "Type", "Glyph Bounds", "Measure Type by Glyph Bounds", bool), // Artboard Options › Global: the canvas darkens the area outside the artboards while the Artboard tool is active. p!("fadeRegionOutsideArtboard", "General", "Artboard Options", "Fade Region Outside Artboard", bool), p!("artboardUpdateWhileDragging", "General", "Artboard Options", "Update While Dragging", bool), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index a1bb2c64e..b6d20999a 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1611,6 +1611,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, }; super::create::new_type_alignment(s, p, C, &mut t)?; if !on_path { @@ -1901,6 +1902,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, }; for r in &mut h.runs { r.style.tracking = tr; diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index c0e5b37fe..1bf212717 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -304,6 +304,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); + t.cached_ink = Some((lay.bounds, lay.ink_bounds())); } /// [`refresh_bounds`] for a document that is opening: the first frame takes the layout @@ -315,6 +316,7 @@ pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); + t.cached_ink = Some((lay.bounds, lay.ink_bounds())); vectorcraft_text::keep_layout(t, stamp, lay); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d3b034526..e6fa7cb8d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -41,6 +41,11 @@ pub struct Prefs { /// Align panel menu › Align to Glyph Bounds › Area Text: the same for area type, instead of /// its frame. pub align_glyph_bounds_area: bool, + /// Preferences › Type › Glyph Bounds › Measure Type by Glyph Bounds (also in the Transform + /// panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, + /// Y, W and H, and `object.setBounds` measure point type selected on its own by the ink of its + /// glyphs instead of its em box ([`crate::Session::bounds_measure`]). + pub type_glyph_bounds: bool, /// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the /// canvas draws everything outside the artboards darker, the pasteboard and the art on it. pub fade_region_outside_artboard: bool, @@ -370,6 +375,7 @@ impl Default for Prefs { use_preview_bounds: false, align_glyph_bounds_point: false, align_glyph_bounds_area: false, + type_glyph_bounds: false, fade_region_outside_artboard: true, artboard_update_while_dragging: true, display_print_size: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 000741d5d..6063feab4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -186,6 +186,7 @@ mod transformfx; mod transparencygrid; mod trap; mod typearea; +mod typeglyphbounds; mod typescale; mod typeundo; mod units; diff --git a/crates/engine/src/tests/typeglyphbounds.rs b/crates/engine/src/tests/typeglyphbounds.rs new file mode 100644 index 000000000..0f42fcc7f --- /dev/null +++ b/crates/engine/src/tests/typeglyphbounds.rs @@ -0,0 +1,297 @@ +//! Preferences › Type › Measure Type by Glyph Bounds (`typeGlyphBounds`): the bounding box, the +//! Transform panel's values and `object.setBounds` measure point type selected on its own by the +//! ink of its glyphs instead of its em box. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind, OrientedBox, TextObject}; +use vectorcraft_geom::Rect; + +use crate::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A new document holding point type made by `text.create` with `params`, selected. +fn with_type(params: Value) -> (Session, NodeId) { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 800, "height": 800})); + let t = id(run(&mut s, "text.create", params)); + run(&mut s, "select.set", json!({"ids": [t.0]})); + (s, t) +} + +fn glyphs(s: &mut Session, on: bool) { + run(s, "prefs.set", json!({"values": {"typeGlyphBounds": on}})); +} + +fn text(s: &Session, id: NodeId) -> TextObject { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("{id:?} is not type") }; + (**t).clone() +} + +/// The ink of the type's glyphs on the page, laid out afresh (not from the cache). +fn ink(s: &Session, id: NodeId) -> Rect { + let t = text(s, id); + t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()) +} + +/// The em box (the line box) of point type: what the bounding box measures by default. +fn em(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn near(a: Rect, b: Rect, tol: f64) -> bool { + (a.x0 - b.x0).abs() <= tol && (a.y0 - b.y0).abs() <= tol && (a.x1 - b.x1).abs() <= tol && (a.y1 - b.y1).abs() <= tol +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The glyph box of `c` in Inter Regular, in font units (x min, y min, x max, y max), and the +/// font's units per em, read from the font file's own `head`, `cmap`, `loca` and `glyf` tables. +fn inter_glyph_box(c: char) -> ([f64; 4], f64) { + use skrifa::MetadataProvider; + use skrifa::raw::TableProvider; + let font = skrifa::FontRef::new(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let upem = f64::from(font.head().unwrap().units_per_em()); + let gid = font.charmap().map(c).unwrap(); + let glyph = font.loca(None).unwrap().get_glyf(gid, &font.glyf().unwrap()).unwrap().unwrap(); + ([glyph.x_min(), glyph.y_min(), glyph.x_max(), glyph.y_max()].map(f64::from), upem) +} + +/// "H" in Inter at 100 pt is drawn with straight lines only, so the box of its outline is the glyph +/// box the font file stores: the bounding box takes its size and place from the font itself. +#[test] +fn point_type_measures_the_glyph_box_the_font_file_stores() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "font": "Inter", "text": "H"})); + glyphs(&mut s, true); + let ([x0, y0, x1, y1], upem) = inter_glyph_box('H'); + let k = 100.0 / upem; + let b = s.transform_box(&[t]).unwrap(); + assert_eq!(b.angle, 0.0); + // Text space runs down the page from the baseline at y = 300. + let want = Rect::new(100.0 + x0 * k, 300.0 - y1 * k, 100.0 + x1 * k, 300.0 - y0 * k); + assert!(near(b.rect, want, 1e-6), "{:?} != {want:?}", b.rect); + assert!(near(s.transform_bounds(&[t]).unwrap(), want, 1e-6)); + // The em box is taller than the cap height. + assert!(em(&s, t).height() > b.rect.height() + 10.0, "em box {:?}", em(&s, t)); +} + +#[test] +fn off_by_default_the_box_is_the_em_box() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + assert!(!s.prefs.type_glyph_bounds); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t)); + glyphs(&mut s, true); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-9)); + glyphs(&mut s, false); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t)); +} + +/// A typed height scales the type so its ink takes that height, in one undo step, and the type +/// stays type. +#[test] +fn set_bounds_gives_the_ink_the_typed_size() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let xf = text(&s, t).xf; + let n = undo_len(&s); + run(&mut s, "object.setBounds", json!({"height": 50})); + assert_eq!(undo_len(&s), n + 1); + assert!((ink(&s, t).height() - 50.0).abs() < 1e-6, "{:?}", ink(&s, t)); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-6)); + assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, t).xf, xf); +} + +/// A typed X moves the ink box's reference point there and keeps its size; a typed width gives +/// the ink that width. +#[test] +fn set_bounds_moves_and_sizes_the_ink_box() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let before = ink(&s, t); + run(&mut s, "object.setBounds", json!({"x": 400, "reference": 0})); + let moved = ink(&s, t); + assert!((moved.x0 - 400.0).abs() < 1e-6 && (moved.y0 - before.y0).abs() < 1e-6, "{moved:?}"); + assert!((moved.width() - before.width()).abs() < 1e-6 && (moved.height() - before.height()).abs() < 1e-6, "{moved:?}"); + run(&mut s, "object.setBounds", json!({"width": 123})); + assert!((ink(&s, t).width() - 123.0).abs() < 1e-6, "{:?}", ink(&s, t)); + assert!((ink(&s, t).height() - before.height()).abs() < 1e-6, "{:?}", ink(&s, t)); +} + +#[test] +fn set_bounds_with_the_current_values_records_no_undo_step() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + let n = undo_len(&s); + run(&mut s, "object.setBounds", json!({"x": b.center().x, "y": b.center().y, "width": b.width(), "height": b.height()})); + assert_eq!(undo_len(&s), n); + assert_eq!(s.transform_box(&[t]).unwrap().rect, b); +} + +/// A proportional width scales both sides of the ink by the same factor, about the reference +/// point of the ink box (here its top-left corner). +#[test] +fn proportional_width_keeps_the_ink_in_proportion_about_the_reference_point() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let before = ink(&s, t); + run(&mut s, "object.setBounds", json!({"width": before.width() * 2.0, "proportional": true, "reference": 0})); + let after = ink(&s, t); + assert!((after.width() - before.width() * 2.0).abs() < 1e-6, "{after:?}"); + assert!((after.height() - before.height() * 2.0).abs() < 1e-6, "{after:?}"); + assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} -> {after:?}"); +} + +/// Rotated type keeps its own angle, and the box is the size of the ink before the rotation. A +/// skewed object's box is the box of its glyph outlines, not of its mapped ink rectangle. +#[test] +fn rotated_and_skewed_type_measure_their_outlines() { + let (mut s, flat) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let size = s.transform_box(&[flat]).unwrap().rect.size(); + let t = id(run(&mut s, "text.create", json!({"x": 300, "y": 300, "size": 100, "text": "Hg", "rotation": 30}))); + let b = s.transform_box(&[t]).unwrap(); + assert_eq!(b.angle, s.doc().unwrap().doc.oriented_bounds(&[t], false).unwrap().angle); + assert!(b.angle != 0.0); + assert!((b.rect.width() - size.width).abs() < 1e-6 && (b.rect.height() - size.height).abs() < 1e-6, "{b:?} vs {size:?}"); + + let o = id(run(&mut s, "text.create", json!({"x": 100, "y": 600, "size": 100, "text": "o"}))); + run(&mut s, "select.set", json!({"ids": [o.0]})); + run(&mut s, "object.transform", json!({"matrix": [1, 0, 0.6, 1, -360, 0]})); + let b = s.transform_box(&[o]).unwrap(); + let tx = text(&s, o); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx); + let to_local = OrientedBox { angle: b.angle, rect: Rect::ZERO }.to_doc().inverse(); + let want = lay.ink_bounds_in(to_local * tx.xf).unwrap(); + assert!(near(b.rect, want, 1e-9), "{:?} != {want:?}", b.rect); + let mapped = (to_local * tx.xf).transform_rect_bbox(lay.ink_bounds().unwrap()); + assert!(mapped.width() > b.rect.width() + 1.0, "a round letter's outline is narrower than its slanted box"); +} + +/// Type that draws nothing is measured by its em box. +#[test] +fn empty_and_space_only_type_keep_the_em_box() { + for text in ["", " "] { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 40, "text": text})); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t), "{text:?}"); + } +} + +/// Area type keeps its frame, and type on a path its path. +#[test] +fn area_type_and_type_on_a_path_are_measured_as_before() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 800, "height": 800})); + let area = id(run(&mut s, "text.create", json!({"x": 100, "y": 100, "size": 24, "text": "ace", "area": {"width": 200, "height": 100}}))); + let line = id(run(&mut s, "shape.line", json!({"x1": 100, "y1": 400, "x2": 500, "y2": 400}))); + let path = id(run(&mut s, "text.createInPath", json!({"path": line.0, "mode": "onPath", "text": "ace", "size": 24}))); + for t in [area, path] { + let off = s.transform_box(&[t]).unwrap(); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[t]).unwrap(), off, "{t:?}"); + glyphs(&mut s, false); + } +} + +#[test] +fn vertical_point_type_measures_its_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 100, "size": 60, "text": "ace", "vertical": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, ink(&s, t), 1e-9), "{b:?} != {:?}", ink(&s, t)); + assert!(em(&s, t).width() > b.width() + 1.0, "em box {:?}", em(&s, t)); +} + +/// With Use Preview Bounds, the characters' strokes paint around the glyphs. +#[test] +fn preview_bounds_add_the_character_strokes_to_the_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + run(&mut s, "text.setStyle", json!({"ids": [t.0], "stroke": "#000000", "strokeWidth": 6})); + run(&mut s, "prefs.set", json!({"values": {"typeGlyphBounds": true, "usePreviewBounds": true}})); + let o = text(&s, t).char_stroke_outset(); + assert!(o > 0.0); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, ink(&s, t).inflate(o, o), 1e-9), "{b:?}"); + // A typed height is the height with the strokes. + run(&mut s, "object.setBounds", json!({"height": 80})); + assert!((s.transform_box(&[t]).unwrap().rect.height() - 80.0).abs() < 1e-6, "{:?}", s.transform_box(&[t])); +} + +/// Type inside a selected group is measured as before: the group's box does not change. +#[test] +fn a_group_holding_type_keeps_its_box() { + let (mut s, _) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let g = id(run(&mut s, "object.group", json!({}))); + let off = s.transform_box(&[g]).unwrap(); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[g]).unwrap(), off); +} + +/// Runs in two fonts: the box covers the ink of both, and follows an edit of the type. +#[test] +fn mixed_fonts_measure_the_ink_of_every_run() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hgxo"})); + glyphs(&mut s, true); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "font": "Source Serif 4", "start": 2, "end": 4})); + let tx = text(&s, t); + assert!(tx.runs.iter().any(|r| r.style.font_family == "Source Serif 4") && tx.runs.len() > 1, "{:?}", tx.runs); + assert!(tx.ink().is_some(), "the edit refreshed the cached ink"); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-9)); +} + +/// A selection of type and a shape is measured by the type's ink and the shape's bounds. +#[test] +fn a_mixed_selection_takes_the_ink_and_the_shapes_bounds() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let r = id(run(&mut s, "shape.rectangle", json!({"x": 500, "y": 500, "width": 50, "height": 50}))); + glyphs(&mut s, true); + assert!(near(s.transform_bounds(&[t, r]).unwrap(), ink(&s, t).union(Rect::new(500.0, 500.0, 550.0, 550.0)), 1e-9)); +} + +#[test] +fn the_preference_is_checked_and_saved() { + let mut s = Session::new(); + assert!(s.execute("prefs.set", &json!({"values": {"typeGlyphBounds": "yes"}})).is_err()); + assert!(!s.prefs.type_glyph_bounds); + assert!(!serde_json::from_value::(json!({})).unwrap().type_glyph_bounds); + run(&mut s, "prefs.set", json!({"key": "typeGlyphBounds", "value": true})); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["typeGlyphBounds"], json!(true)); + assert!(serde_json::from_value::(saved).unwrap().type_glyph_bounds); + assert_eq!(run(&mut s, "prefs.get", json!({"key": "typeGlyphBounds"})), json!(true)); +} + +/// The preference is not part of the document: a saved document is the same with it on or off. +#[test] +fn saved_documents_do_not_depend_on_the_preference() { + let (mut s, _) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let off = serde_json::to_string(&s.doc().unwrap().doc).unwrap(); + assert!(!off.contains("cached_ink") && !off.contains("cachedInk"), "the cached ink is not saved"); + glyphs(&mut s, true); + assert_eq!(serde_json::to_string(&s.doc().unwrap().doc).unwrap(), off); +} + +/// A transform of type keeps its cached ink, which is in text space. +#[test] +fn the_cached_ink_is_the_laid_out_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let tx = text(&s, t); + assert_eq!(tx.ink(), Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds())); + run(&mut s, "object.transform", json!({"matrix": [2, 0, 0, 3, 0, 0]})); + assert_eq!(text(&s, t).ink(), tx.ink()); + // A cache measured with other bounds is not read. + let mut stale = text(&s, t); + stale.cached_bounds = Some(Rect::new(0.0, 0.0, 1.0, 1.0)); + assert_eq!(stale.ink(), None); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d8b32eab7..e818c3686 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -365,6 +365,7 @@ impl Session { freeform_point: self.selected_freeform_point(), raster_sample: self.prefs.eyedropper.sample_size, preview_bounds: self.prefs.use_preview_bounds, + glyph_bounds: self.prefs.type_glyph_bounds, preview_memo: Some(&self.preview_memo), unit, stroke_unit, diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 054db258d..971323bf1 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -178,6 +178,9 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, + /// Type › Measure Type by Glyph Bounds: the bounding box measures point type selected on its + /// own by the ink of its glyphs ([`vectorcraft_effects::preview::Measure`]). + pub glyph_bounds: bool, /// The session's memo of the selection tools' measurements for the document as it is /// ([`select::PreviewMemo`]); without one they measure on every call. pub preview_memo: Option<&'a select::PreviewMemo>, @@ -284,6 +287,11 @@ pub struct ToolContext<'a> { } impl ToolContext<'_> { + /// How the bounding box measures the selection: [`Self::preview_bounds`] and + /// [`Self::glyph_bounds`]. + pub fn bounds_measure(&self) -> vectorcraft_effects::preview::Measure { + vectorcraft_effects::preview::Measure { preview: self.preview_bounds, glyphs: self.glyph_bounds } + } /// Tolerance in document units for `px` screen pixels. pub fn tol(&self, px: f64) -> f64 { px / self.zoom.max(1e-9) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a2ffbbf87..833f48225 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1437,7 +1437,7 @@ impl DirectSelectionTool { // them, an edge on a gridline on each axis), not that point (#3835). let grid = segment && (cx.snap_to_grid || cx.snap_to_pixel); self.partial_grid = - grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); } let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let unsnapped = d; @@ -1474,7 +1474,7 @@ impl DirectSelectionTool { self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snap_to_pixel); self.partial_grid = - grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); let copy = ev.mods.alt && self.partial_snap.is_none(); out.push(Action::Begin(if copy { "Copy".into() } else { "Move".into() })); self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 557fe52b1..b4a7d6997 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -536,6 +536,7 @@ pub(crate) mod testutil { freeform_point: None, raster_sample: 1, preview_bounds: false, + glyph_bounds: false, preview_memo: None, unit: Unit::Points, stroke_unit: Unit::Points, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index e39837c7c..fe666bd67 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -419,7 +419,7 @@ struct MemoState { struct Measured { revision: (u64, u64), objects: Vec, - preview: bool, + measure: vectorcraft_effects::preview::Measure, bounds: Option>, oriented: Option>, by_object: HashMap>>, @@ -427,7 +427,7 @@ struct Measured { impl Measured { fn is_for(&self, cx: &ToolContext) -> bool { - self.revision == cx.revision && self.preview == cx.preview_bounds && self.objects == cx.selection.objects + self.revision == cx.revision && self.measure == cx.bounds_measure() && self.objects == cx.selection.objects } } @@ -450,7 +450,7 @@ impl PreviewMemo { let fresh = || Measured { revision: cx.revision, objects: cx.selection.objects.clone(), - preview: cx.preview_bounds, + measure: cx.bounds_measure(), bounds: None, oriented: None, by_object: HashMap::new(), @@ -487,15 +487,16 @@ fn remembered(cx: &ToolContext, slot: impl Fn(&mut Measured) -> &mut O } /// Selection bounds used for the bounding box: visual bounds as drawn with Use Preview Bounds -/// ([`vectorcraft_effects::preview`]), else geometric bounds. +/// ([`vectorcraft_effects::preview`]), else geometric bounds, with point type measured by its +/// glyphs with Measure Type by Glyph Bounds ([`ToolContext::bounds_measure`]). pub fn selection_bounds(cx: &ToolContext) -> Option { - remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of(cx.doc, &cx.selection.objects, cx.preview_bounds)) + remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of_with(cx.doc, &cx.selection.objects, cx.bounds_measure())) } /// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool /// shows and drags (rotated after a rotation). pub fn selection_box(cx: &ToolContext) -> Option { - remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds(cx.doc, &cx.selection.objects, cx.preview_bounds)) + remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds_with(cx.doc, &cx.selection.objects, cx.bounds_measure())) } /// [`selection_box`] while the canvas shows it (View › Show Bounding Box, and no anchors selected @@ -527,7 +528,7 @@ fn object_bounds(cx: &ToolContext, id: NodeId) -> Option { /// which compares the whole selection: a walk of every selected object checking it each time takes /// time in the square of their number. fn with_object_bounds(cx: &ToolContext, walk: impl FnOnce(&mut dyn FnMut(NodeId) -> Option) -> R) -> R { - let mut measure = |id: NodeId| vectorcraft_effects::preview::bounds_of(cx.doc, &[id], cx.preview_bounds); + let mut measure = |id: NodeId| vectorcraft_effects::preview::bounds_of_with(cx.doc, &[id], cx.bounds_measure()); let Some(mut st) = cx.preview_memo.and_then(|memo| memo.0.try_borrow_mut().ok()) else { return walk(&mut measure); }; diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 9921d8f5b..2b1151742 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2255,6 +2255,30 @@ fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() { assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0)); } +/// With Measure Type by Glyph Bounds, the bounding box of selected point type is the ink of its +/// glyphs; the memo measures again when only that setting changes. +#[test] +fn the_bounding_box_of_point_type_follows_measure_type_by_glyph_bounds() { + use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject}; + let mut d = Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let text = TextObject::point(Point::new(100.0, 300.0), "Hg", CharStyle { size: 60.0, ..Default::default() }); + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text).ink_bounds().unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let (mut s, p, memo) = (Selection::default(), paint(), PreviewMemo::default()); + s.set([id]); + let at = |glyph_bounds| { + let c = ToolContext { revision: (1, 1), glyph_bounds, preview_memo: Some(&memo), ..cx(&d, &s, &p) }; + (selection_box(&c).map(|b| b.rect), object_bounds(&c, id)) + }; + let em = d.node(id).and_then(|n| n.geometric_bounds()); + assert_eq!(at(false), (em, em)); + let glyphs = Some(text.xf.transform_rect_bbox(ink)); + assert_eq!(at(true), (glyphs, glyphs)); + assert_ne!(glyphs, em); + assert_eq!(memo.measurements(), 4, "the same revision measured again for the setting"); +} + /// A double-click on a symbol instance edits its symbol in symbol editing mode (`symbol.edit`), and /// one outside the symbol's art in that mode leaves it, as it leaves isolation mode. An instance /// inside the art of the symbol being edited is not entered. diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 04ecc971b..5cabbed6b 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -118,7 +118,7 @@ impl TransformTool { return Some(*p); } // Anchors selected with Direct Selection transform alone, about their own centre. - vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds) + vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()) .map(|b| b.center()) .or_else(|| selection_box(cx).map(|b| b.center())) } diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 989514991..6aa3eab76 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -137,13 +137,14 @@ impl VectorcraftApp { /// frame: it is measured once per revision. pub fn selection_box(&mut self) -> Option { let st = self.session.active()?; - let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); + let measure = self.session.bounds_measure(); + let key = (st.uid, st.revision, measure); if let Some((k, b)) = self.canvas.selection_box && k == key { return b; } - let b = match vectorcraft_effects::preview::anchor_bounds(&st.selection, &st.doc, self.session.prefs.use_preview_bounds) { + let b = match vectorcraft_effects::preview::anchor_bounds_with(&st.selection, &st.doc, measure) { Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)), None => self.session.transform_box(&st.selection.objects), }; diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index d8ec2b10e..1e69fc57d 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -51,8 +51,9 @@ pub struct CanvasCache { /// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that /// does not draw them drops them ([`canvas::show`]). pub selection_marks: canvas::SelectionMarks, - /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). - pub selection_box: Option<((u64, u64, bool), Option)>, + /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds and + /// Measure Type by Glyph Bounds). + pub selection_box: Option<((u64, u64, vectorcraft_effects::preview::Measure), Option)>, /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). pub selection_bounds: Option<((u64, u64), Option)>, /// [`VectorcraftApp::content_bounds`] for (document uid, revision). diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b7006..1287aae70 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5896,6 +5896,8 @@ axis Y Axis Y ос # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точков текст Area Text Текст в зона +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Измерване на текста по границите на глифа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показване на опциите за прилепване към глиф Hide Snap to Glyph Options Скриване на опциите за прилепване към глиф diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a655..8c11a1cb0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5952,6 +5952,8 @@ axis Y Axis Y osa Live preview Pregled uživo Point Text Tačkasti tekst Area Text Tekst u oblasti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mjeri tekst po granicama glifa Show Snap to Glyph Options Prikaži opcije prianjanja uz glif Hide Snap to Glyph Options Sakrij opcije prianjanja uz glif Snap to Glyph Options Opcije prianjanja uz glif diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index bc984eec5..489ece200 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6067,6 +6067,8 @@ theme Appearance Aparença # Align panel menu › Align to Glyph Bounds (#4231). Point Text Text de punt Area Text Text d’àrea +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mesura el text pels límits del glif # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra les opcions d’ajust al glif Hide Snap to Glyph Options Amaga les opcions d’ajust al glif diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fc357028f..707dfb8d8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6104,6 +6104,8 @@ axis Y Axis osa Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Bodový text Area Text Text v oblasti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Měřit text podle hranic glyfů # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Zobrazit možnosti přichytávání ke glyfům Hide Snap to Glyph Options Skrýt možnosti přichytávání ke glyfům diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718b..c860cf011 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5911,6 +5911,8 @@ axis Y Axis Y-akse # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttekst Area Text Områdetekst +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mål tekst efter glyfafgrænsning # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Vis indstillinger for fastgørelse til tegn Hide Snap to Glyph Options Skjul indstillinger for fastgørelse til tegn diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b66afbe45..9cc4bd50b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5687,6 +5687,8 @@ axis Y Axis Y-Achse # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttext Area Text Flächentext +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Text an Glyphenbegrenzung messen # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Optionen für „An Glyphe ausrichten“ einblenden Hide Snap to Glyph Options Optionen für „An Glyphe ausrichten“ ausblenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 632a6feec..b2e307d21 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6055,6 +6055,8 @@ axis Y Axis Άξονας Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Κείμενο σημείου Area Text Κείμενο περιοχής +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Μέτρηση κειμένου με βάση τα όρια γλυφής # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Εμφάνιση επιλογών προσκόλλησης σε γλυφή Hide Snap to Glyph Options Απόκρυψη επιλογών προσκόλλησης σε γλυφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3369528a1..99b717e2f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6046,6 +6046,8 @@ axis Y Axis Eje Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texto de punto Area Text Texto de área +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Medir el texto por los límites del glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar opciones de ajuste a glifo Hide Snap to Glyph Options Ocultar opciones de ajuste a glifo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ba0cff27..f23a250cc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6134,6 +6134,8 @@ axis Y Axis Y-akseli # Align panel menu › Align to Glyph Bounds (#4231). Point Text Pisteteksti Area Text Alueteksti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mittaa teksti merkin rajoista # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Näytä merkkiin kohdistuksen asetukset Hide Snap to Glyph Options Piilota merkkiin kohdistuksen asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6a7817aea..063551fac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6099,6 +6099,8 @@ axis Y Axis Axe Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texte de point Area Text Texte captif +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mesurer le texte d’après les limites du glyphe # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Afficher les options de magnétisme du glyphe Hide Snap to Glyph Options Masquer les options de magnétisme du glyphe diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1d5571d2d..f72d85e92 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -394,6 +394,7 @@ antiAlias Sharp शार्प Area / Path Type एरिया / पाथ टाइप Area Graph Tool एरिया ग्राफ़ टूल Area Text एरिया टेक्स्ट + Measure Type by Glyph Bounds टाइप को ग्लिफ़ की सीमाओं से मापें Area Text Objects एरिया टेक्स्ट ऑब्जेक्ट Area Type एरिया टाइप Area Type Options एरिया टाइप ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 92d7822e4..f065068ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6087,6 +6087,8 @@ axis Y Axis Y tengely # Align panel menu › Align to Glyph Bounds (#4231). Point Text Pontszöveg Area Text Területszöveg +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Szöveg mérése a glifahatárok szerint # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glifához illesztés beállításainak megjelenítése Hide Snap to Glyph Options Glifához illesztés beállításainak elrejtése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f..83d51d692 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5911,6 +5911,8 @@ axis Y Axis Sumbu Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Teks titik Area Text Teks area +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Ukur teks menurut batas glif # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Tampilkan opsi snap ke glif Hide Snap to Glyph Options Sembunyikan opsi snap ke glif diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f710a566..f9c87895e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5721,6 +5721,8 @@ axis Y Axis Asse Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Testo per punti Area Text Testo in area +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Misura il testo in base ai limiti del glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra opzioni di aggancio a glifo Hide Snap to Glyph Options Nascondi opzioni di aggancio a glifo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6403b1e8a..2c219e926 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5786,6 +5786,8 @@ axis Y Axis Y 軸 # Align panel menu › Align to Glyph Bounds (#4231). Point Text ポイント文字 Area Text エリア内文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 文字を字形の境界で測定 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options グリフにスナップのオプションを表示 Hide Snap to Glyph Options グリフにスナップのオプションを隠す diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0b6755253..f333baeeb 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6100,6 +6100,8 @@ swatch Bright Violet კაშკაშა იისფერი # Align panel menu › Align to Glyph Bounds (#4231). Point Text წერტილოვანი ტექსტი Area Text არის ტექსტი +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds ტექსტის გაზომვა გლიფის საზღვრებით # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options გლიფზე მიბმის პარამეტრების ჩვენება Hide Snap to Glyph Options გლიფზე მიბმის პარამეტრების დამალვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 64844bf7c..406af4f70 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5682,6 +5682,8 @@ axis Y Axis Y축 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 포인트 문자 Area Text 영역 문자 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 글리프 경계로 문자 측정 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 글리프에 스냅 옵션 표시 Hide Snap to Glyph Options 글리프에 스냅 옵션 숨기기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3..19028d534 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5962,6 +5962,8 @@ axis Y Axis Y-as # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punttekst Area Text Vlaktekst +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Tekst meten op glyphbegrenzing # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Opties voor uitlijnen op glyph tonen Hide Snap to Glyph Options Opties voor uitlijnen op glyph verbergen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd33ebd05..20b179393 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5958,6 +5958,8 @@ axis Y Axis Oś Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Tekst punktowy Area Text Tekst akapitowy +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mierz tekst według granic glifów # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Pokaż opcje przyciągania do glifów Hide Snap to Glyph Options Ukryj opcje przyciągania do glifów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a80de1e0c..956356193 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6019,6 +6019,8 @@ axis Y Axis Eixo Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texto de Ponto Area Text Texto de Área +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Medir Texto pelos Limites do Glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar Opções de Ajuste ao Glifo Hide Snap to Glyph Options Ocultar Opções de Ajuste ao Glifo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2ddc692a3..294ac503e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6081,6 +6081,8 @@ theme Appearance Оформление # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точечный текст Area Text Текст в области +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Измерять текст по границам глифа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показать параметры привязки к глифу Hide Snap to Glyph Options Скрыть параметры привязки к глифу diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7a0e3f39b..73ec524fa 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6127,6 +6127,8 @@ axis Y Axis Y-axel # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttext Area Text Områdestext +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mät text efter glyfgränser # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Visa alternativ för fäst mot glyf Hide Snap to Glyph Options Dölj alternativ för fäst mot glyf diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 67b958ebd..9768ae1dd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6060,6 +6060,8 @@ axis Y Axis Y ekseni # Align panel menu › Align to Glyph Bounds (#4231). Point Text Nokta Metni Area Text Alan Metni +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Yazıyı Glif Sınırlarına Göre Ölç # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glif'e Yapışma Seçeneklerini Göster Hide Snap to Glyph Options Glif'e Yapışma Seçeneklerini Gizle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67050653e..d41f86f00 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5701,6 +5701,8 @@ axis Y Axis Вісь Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точковий текст Area Text Текст в області +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Вимірювати текст за межами гліфа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показати параметри прив’язки до гліфа Hide Snap to Glyph Options Приховати параметри прив’язки до гліфа diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7c0279792..445a72000 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5702,6 +5702,8 @@ axis Y Axis Trục Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Chữ điểm Area Text Chữ vùng +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Đo chữ theo giới hạn glyph # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Hiện tùy chọn hút vào glyph Hide Snap to Glyph Options Ẩn tùy chọn hút vào glyph diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b7b8f6561..b8720d333 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5942,6 +5942,8 @@ axis Y Axis Y 轴 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 点文字 Area Text 区域文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 按字形边界度量文字 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 显示靠齐字符选项 Hide Snap to Glyph Options 隐藏靠齐字符选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 328cbb1f8..1aff8f69d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5739,6 +5739,8 @@ axis Y Axis Y 軸 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 點狀文字 Area Text 區域文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 依字符邊界測量文字 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 顯示靠齊字符選項 Hide Snap to Glyph Options 隱藏靠齊字符選項 diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index b1f8322fd..2596b218b 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -576,6 +576,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Scale Strokes & Effects"), true, ss) { set_pref(app, "scaleStrokes", !ss); } + // The panel's values and the bounding box measure point type by its glyphs' ink. + let glyphs = app.session.prefs.type_glyph_bounds; + if menu_item(ui, tl!("Measure Type by Glyph Bounds"), true, glyphs) { + set_pref(app, "typeGlyphBounds", !glyphs); + } ui.separator(); // What the panel transforms (Transform Object Only / Pattern Only / Both), one checked. let current = pattern_mode(app); diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index b5598689a..67cebc7cd 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -32,6 +32,26 @@ fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() { assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds")); } +/// The panel menu's Measure Type by Glyph Bounds is checked while `typeGlyphBounds` is on, and the +/// bounding box of selected point type then measures its glyphs without a change to the document. +#[test] +fn measure_type_by_glyph_bounds_is_checked_and_resizes_the_box() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 200, "size": 100, "text": "Hg"})).unwrap(); + assert!(texts(&mut app, menu).iter().any(|t| t == " Measure Type by Glyph Bounds")); + let em = app.selection_box().unwrap().rect; + let revision = app.session.active().unwrap().revision; + set_pref(&mut app, "typeGlyphBounds", true); + assert!(app.session.prefs.type_glyph_bounds); + assert!(texts(&mut app, menu).iter().any(|t| t == "✓ Measure Type by Glyph Bounds")); + assert_eq!(app.session.active().unwrap().revision, revision); + let glyphs = app.selection_box().unwrap().rect; + assert!(glyphs.height() < em.height() - 10.0, "{glyphs:?} inside {em:?}"); + set_pref(&mut app, "typeGlyphBounds", false); + assert_eq!(app.selection_box().unwrap().rect, em); +} + #[test] fn use_preview_bounds_shows_the_size_after_geometry_effects() { // A 100 × 50 rectangle without a stroke, with Offset Path 10: 120 × 70 as drawn. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2cb542c6b..b480106b3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -529,6 +529,10 @@ fields measure visual bounds. Align to Glyph Bounds: the Align panel flyout's Align to Glyph Bounds submenu shows and toggles the `alignGlyphBoundsPoint` (Point Text) and `alignGlyphBoundsArea` (Area Text) preferences (`prefs.set`), checked while on. +Measure Type by Glyph Bounds: the Transform panel menu's Measure Type by Glyph Bounds item shows and toggles the +`typeGlyphBounds` preference (`prefs.set`), checked while on; while it is on, the bounding box and the Transform +panel, Properties and Control bar fields measure point type selected on its own by its glyphs' ink. + Align To: the Align panel's Align To choice is saved with the preferences, and `ui.inspect` reports it as `ui.align_to` (`"selection"` or `"artboard"`; Align to Key Object stores no choice). The align buttons of the Align panel, the Control bar and the Properties panel run `object.align` with `to: "artboard"` when one object is selected, diff --git a/docs/mcp.md b/docs/mcp.md index 2618ebfa4..c33243f42 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3963,6 +3963,29 @@ an error that lists the choices of a `horizontal`, `vertical`, `to`, `axis` or ` (`object.reflect` also takes an angle and names the value given), and one for a `spacing` that is not a number; nothing moves. +## Glyph bounds for type + +The bounding box normally measures point type by its em box, which leaves room above and below the letters for +ascenders, descenders and capitals. With the preference `typeGlyphBounds` on (`prefs.set {key: "typeGlyphBounds", +value: true}`; also Preferences › Type › Glyph Bounds and the Transform panel menu's Measure Type by Glyph Bounds), the +bounding box and its handles, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, +`object.setBounds`, the Scale, Rotate and Reflect tools' reference point and the Arrange commands measure point type +selected on its own, horizontal or vertical, by the ink of its glyphs. The option is VectorCraft's own and is off by +default. + +A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its +font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt +font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is +measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that +draws nothing (no text, only spaces), area type, type on a path and type inside a selected group are measured as +before. Align and Distribute follow Align to Glyph Bounds, and snapping and clicks do not change. The preference is +not saved in documents. + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeGlyphBounds":true}}}} +{"name":"run_command","arguments":{"command":"object.setBounds","params":{"height":72,"proportional":true}}} +``` + ## Selection preferences The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17cc2849b..2140f5410 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1052,6 +1052,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). + - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds` and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. The Lights list, Add Light and Delete Light manage one to ten independent colored lights; ambient is shared and visibility remains approximate ([revolve.md](revolve.md)). From 49129c565b4ee1b6f359ef49f1461649d004a458 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 20:38:14 -0800 Subject: [PATCH 08/14] Export: exported objects keep the clips, masks and effects around them Export Selection, Export for Screens by selection or by layer, Asset Export and CSS Properties' rasterized pictures put the objects on a new plain layer. An object inside a clip group, or on a clipping layer, was written without its clip, in SVG, PNG and the other formats, and the opacity masks, effects, opacity and blend modes of the groups and layers that held it were dropped too. These exports now keep each object in the layers and groups that hold it, and crop the file to the bounds the renderer measures for that art (a clip group by its clip). Those layers are drawn even when they are hidden, template or non-printing layers, as the objects were before. A PSD of the export names its layers after the document's layers instead of one layer named Selection or after the asset. document.export with selectedOnly uses the same code: an object of a blend, envelope, repeat or intertwine is now written on its own instead of inside a blend of one object, and a Shaper group keeps its source art. A text export of the selection still holds only the selected type, not that of a type clipping path around it. --- crates/engine/src/cmd/assets.rs | 2 +- crates/engine/src/cmd/css.rs | 2 +- crates/engine/src/cmd/fileio.rs | 2 + crates/engine/src/cmd/fileio/commands.rs | 6 +- crates/engine/src/cmd/fileio/export.rs | 115 ++++-- .../engine/src/cmd/fileio/tests_selexport.rs | 375 ++++++++++++++++++ docs/mcp.md | 7 +- docs/target-app-parity.md | 2 + 8 files changed, 473 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_selexport.rs diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 8c225b877..31639519f 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Assets", [], None, - "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included, with the clips of the groups and layers that hold it), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", has_doc, list ), diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index 1780cf2be..ed83a0141 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", + "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index b913720f1..04a443ed4 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -184,6 +184,8 @@ mod tests_batch; #[cfg(test)] mod tests_screens; #[cfg(test)] +mod tests_selexport; +#[cfg(test)] mod tests_text; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ce7a82d2b..81e8168d0 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index a004fad89..02dda6f0f 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -94,27 +94,63 @@ pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result) -> Option { +/// The layers of `doc` with only the objects in `keep` (whole, with everything inside them), each +/// in the layers, groups and compound paths that hold it. Those keep what they apply to the object +/// on the canvas: their clipping paths, opacity masks, effects, opacity and blend modes. An object +/// of a blend, envelope, repeat or intertwine goes in on its own, without the art made from it. With +/// `shown`, the layers and groups that hold the objects are visible, and those layers print and are +/// not template layers. +fn enclosed(doc: &Document, keep: &HashSet, shown: bool) -> Vec> { + let holders: HashSet = keep.iter().filter_map(|id| doc.ancestry(*id)).flatten().filter(|id| !keep.contains(id)).collect(); + let mut out = vec![]; + for l in &doc.layers { + selected_part(l, keep, &holders, shown, &mut out); + } + out +} + +/// The parts of `n` in `keep` (whole, with everything inside them), added to `out`: `n` itself, or +/// `n` with only those parts when it is one of their `holders`, made visible, printable and not a +/// template with `shown`. +fn selected_part(n: &Arc, keep: &HashSet, holders: &HashSet, shown: bool, out: &mut Vec>) { if keep.contains(&n.id) { - return Some(n.clone()); + out.push(n.clone()); + return; + } + let Some(children) = n.children().filter(|_| holders.contains(&n.id)) else { return }; + let mut kept = vec![]; + for c in children { + selected_part(c, keep, holders, shown, &mut kept); + } + if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. }) { + out.extend(kept); + return; } - let children = n.children()?; - let mut kept: Vec> = children.iter().filter_map(|c| selected_part(c, keep).map(Arc::new)).collect(); if kept.is_empty() { - return None; + return; } - let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); - if let Some(first) = children.first().filter(|f| clip && kept.first().is_none_or(|k| k.id != f.id)) { + // A clipping group or layer keeps its clipping path; a Shaper group its source art, which + // draws nothing. + let first_stays = n.clips() || n.extras().shaper.is_some(); + if let Some(first) = children.first().filter(|f| first_stays && kept.first().is_none_or(|k| k.id != f.id)) { kept.insert(0, first.clone()); } - let mut out = n.clone(); - *out.children_mut()? = kept; - Some(out) + let mut part = Node::clone(n); + if let Some(ch) = part.children_mut() { + *ch = kept; + } + if shown { + part.visible = true; + if let NodeKind::Layer { template, printable, .. } = &mut part.kind { + *template = false; + *printable = true; + } + } + out.push(Arc::new(part)); } /// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left @@ -202,7 +238,7 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> { if f.id == "txt" && bool_or(p, "selectionOnly", false) { - let doc = selection(s, cmd)?.0; + let doc = selection(s, cmd, true)?.0; let display_doc_title = super::has_own_title(&doc); return Ok((Cow::Owned(doc), display_doc_title)); } @@ -212,11 +248,21 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title)) } -/// The selected objects (not on template layers) alone on one layer, with one artboard: their -/// visual bounds. -fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> { +/// The selected objects (not on template layers) in the layers and groups that hold them +/// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a +/// text export holds the type of the selected objects and not that of a clipping path around them. +fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> { let ids = selected_artwork(s)?; - isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export")) + let doc = &s.doc()?.doc; + let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?; + if text { + let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect(); + } + d.layers = vec![Arc::new(layer)]; + } + Ok((d, bounds)) } /// The selected objects (their top-most selected ancestors) that are artwork: those on template @@ -231,25 +277,30 @@ pub(crate) fn selected_artwork(s: &Session) -> Result> { Ok(ids.into_iter().filter(|id| !on_template(*id)).collect()) } -/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what -/// it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; symbol -/// instances are measured by their art), `None` when they have none. +/// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer +/// measures for what it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; +/// symbol instances are measured by their art), with what the layers and groups that hold them +/// apply (a clipping group or layer is measured by its clip), `None` when they have none. pub(crate) fn export_crop(doc: &Document, ids: &[NodeId]) -> Option { - let symbols = doc.symbol_boxes(); - ids.iter().filter_map(|id| vectorcraft_render::effect_bounds(&symbols, doc.node(*id)?)).reduce(|a, b| a.union(b)) + isolated_art(doc, ids).1 } -/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard -/// named `name`: their [`export_crop`] (`None` when they have none). +/// The layers of [`isolated`] and their crop. +fn isolated_art(doc: &Document, ids: &[NodeId]) -> (Vec>, Option) { + let keep: HashSet = ids.iter().copied().collect(); + let layers = enclosed(doc, &keep, true); + let symbols = doc.symbol_boxes(); + let bounds = layers.iter().filter_map(|l| vectorcraft_render::effect_bounds(&symbols, l)).reduce(|a, b| a.union(b)); + (layers, bounds) +} + +/// Objects `ids` of `doc` alone, each in the layers and groups that hold it ([`enclosed`], made +/// visible), with one artboard named `name`: their [`export_crop`] (`None` when they have none). pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> { - let bounds = export_crop(doc, ids)?; - let nodes: Vec> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect(); + let (layers, bounds) = isolated_art(doc, ids); + let bounds = bounds?; let mut d = single_artboard(doc, bounds, name); - let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0)); - if let Some(ch) = layer.children_mut() { - *ch = nodes; - } - d.layers = vec![Arc::new(layer)]; + d.layers = layers; Some((d, bounds)) } @@ -265,7 +316,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { let f = writable(C, str_param(p, "format"), path)?; let expanded = expand_presets(s, C, p)?; let p = with_black_output(s, &expanded); - let (d, bounds) = selection(s, C)?; + let (d, bounds) = selection(s, C, f.id == "txt")?; // The encoder's warnings (options accepted but not applied, features approximated or left // out), as document.export reports them. let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?; diff --git a/crates/engine/src/cmd/fileio/tests_selexport.rs b/crates/engine/src/cmd/fileio/tests_selexport.rs new file mode 100644 index 000000000..57db4891a --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_selexport.rs @@ -0,0 +1,375 @@ +//! Exports of objects alone (Export Selection, Export for Screens by selection or by layer, Asset +//! Export, a CSS rule's picture and `selectedOnly`) draw the objects as the canvas draws them: with +//! the clipping paths, opacity masks, effects, opacity and blend modes of the layers and groups that +//! hold them. + +use serde_json::{Value, json}; +use vectorcraft_geom::Rect; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap(); + s +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> u64 { + v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}")) +} + +/// A red 300 pt square at (100, 100) clipped by a 200 pt circle at (150, 150): (square, clip group). +fn clipped(s: &mut Session) -> (u64, u64) { + let square = id(&run(s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300}))); + run(s, "paint.setFill", json!({"color": "#ff0000"})); + let circle = id(&run(s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200}))); + let group = id(&run(s, "object.clippingMask.make", json!({"ids": [circle, square]}))); + (square, group) +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap() +} + +fn rect_of(v: &Value) -> Rect { + let n = |i: usize| v[i].as_f64().unwrap_or_else(|| panic!("bad bounds {v}")); + Rect::new(n(0), n(1), n(0) + n(2), n(1) + n(3)) +} + +/// What the canvas draws of the whole document over `r`, at 1 px per point. +fn canvas(s: &Session, r: Rect) -> image::RgbaImage { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, r, 1.0, false); + image::load_from_memory(&img.to_png().unwrap()).unwrap().to_rgba8() +} + +/// The pixels of `png` that differ from `want` by more than 64 in a channel (anti-aliased edges +/// differ by less), and every pixel when the sizes differ. +fn differing(png: &[u8], want: &image::RgbaImage) -> usize { + let got = image::load_from_memory(png).unwrap().to_rgba8(); + if got.dimensions() != want.dimensions() { + return (want.width() * want.height()) as usize; + } + got.pixels().zip(want.pixels()).filter(|(a, b)| a.0.iter().zip(b.0.iter()).any(|(x, y)| x.abs_diff(*y) > 64)).count() +} + +/// Export Selection of what is selected, as a PNG, matches the canvas over its crop: fewer than 200 +/// pixels differ (anti-aliased edges), or 200 times `edges` with [`selection_png_matches_canvas_with`]. +fn selection_png_matches_canvas(s: &mut Session, want: Rect, what: &str) { + selection_png_matches_canvas_with(s, want, what, 1); +} + +fn selection_png_matches_canvas_with(s: &mut Session, want: Rect, what: &str, edges: usize) { + let r = run(s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let got = rect_of(&r["bounds"]); + assert!((got.x0 - want.x0).abs() < 1.5 && (got.y0 - want.y0).abs() < 1.5, "{what}: crop {got:?}, want {want:?}"); + assert!((got.width() - want.width()).abs() < 3.0 && (got.height() - want.height()).abs() < 3.0, "{what}: crop {got:?}, want {want:?}"); + let off = differing(&b64(&r), &canvas(s, got)); + assert!(off < 200 * edges, "{what}: {off} pixels differ from the canvas"); +} + +#[test] +fn export_selection_of_a_clipped_object_keeps_its_clip() { + let mut s = session(); + let (square, _) = clipped(&mut s); + run(&mut s, "select.set", json!({"ids": [square]})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "the square alone"); + let svg = b64(&run(&mut s, "document.exportSelection", json!({"format": "svg"}))); + let text = String::from_utf8_lossy(&svg); + assert!(text.contains(" 352.0 && got.y1 > 352.0, "the crop takes in the clip group's shadow: {got:?}"); + assert!(differing(&b64(&r), &canvas(&s, got)) < 200, "with the shadow"); + run(&mut s, "edit.undo", json!({})); + // An opacity mask on the clip group. + let mask = id(&run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300}))); + run( + &mut s, + "paint.setFill", + json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}), + ); + run(&mut s, "transparency.makeOpacityMask", json!({"ids": [mask, group]})); + run(&mut s, "select.set", json!({"ids": [square]})); + let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let got = rect_of(&r["bounds"]); + assert!(differing(&b64(&r), &canvas(&s, got)) < 200, "with an opacity mask on the clip group"); +} + +#[test] +fn export_selection_across_clip_groups_and_of_whole_ones() { + let mut s = session(); + let (a, ga) = clipped(&mut s); + let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 90, "height": 90}))); + let c = id(&run(&mut s, "shape.rectangle", json!({"x": 420, "y": 420, "width": 50, "height": 50}))); + let gb = id(&run(&mut s, "object.clippingMask.make", json!({"ids": [c, b]}))); + // A member of each clip group: each keeps its own clip. + run(&mut s, "select.set", json!({"ids": [a, b]})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 470.0, 470.0), "a member of each of two clip groups"); + // The clip groups themselves. + run(&mut s, "select.set", json!({"ids": [ga, gb]})); + selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 470.0, 470.0), "two clip groups"); + // Nested clip groups: the inner group's member is clipped by both, cropped to the outer clip. + let strip = id(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 200, "width": 500, "height": 100}))); + run(&mut s, "object.clippingMask.make", json!({"ids": [strip, ga]})); + run(&mut s, "select.set", json!({"ids": [a]})); + selection_png_matches_canvas(&mut s, Rect::new(0.0, 200.0, 500.0, 300.0), "a member of nested clip groups"); +} + +#[test] +fn a_selected_clipping_path_and_a_blend_object_export_on_their_own() { + let mut s = session(); + let (square, group) = clipped(&mut s); + let circle = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id.0; + assert_ne!(circle, square); + // The clipping path alone: the clip group's other art stays out. + run(&mut s, "select.set", json!({"ids": [circle]})); + let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (200, 200), "cropped to the clipping path"); + assert!(!img.pixels().any(|p| p[0] > 200 && p[1] < 80 && p[3] > 0), "the clipped square is left out"); + // An object of a blend goes in on its own, without the blend's steps. + let mut s = session(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let a = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 60, "height": 60}))); + let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 60, "height": 60}))); + run(&mut s, "select.set", json!({"ids": [a, b]})); + run(&mut s, "object.blend.make", json!({})); + run(&mut s, "select.set", json!({"ids": [a]})); + let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1})); + let got = rect_of(&r["bounds"]); + assert!(got.x1 < 90.0 && got.y1 < 90.0, "the blend object's own bounds: {got:?}"); + let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8(); + let centre = img.get_pixel(img.width() / 2, img.height() / 2); + assert!(centre[0] > 200 && centre[3] > 200, "the object is drawn: {centre:?}"); +} + +#[test] +fn a_css_picture_of_a_clipped_object_is_clipped() { + let mut s = session(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let star = id(&run(&mut s, "shape.star", json!({"cx": 250, "cy": 250, "radius1": 150, "radius2": 70, "points": 5}))); + let circle = id(&run(&mut s, "shape.ellipse", json!({"x": 200, "y": 200, "width": 100, "height": 100}))); + run(&mut s, "object.clippingMask.make", json!({"ids": [circle, star]})); + let r = run(&mut s, "css.export", json!({"ids": [star], "rasterize": true, "unnamed": true})); + let image = r["images"].as_array().and_then(|i| i.first()).unwrap_or_else(|| panic!("no picture: {r}")); + let png = b64(image); + let size = image::load_from_memory(&png).unwrap().to_rgba8().dimensions(); + assert_eq!(size, (100, 100), "the picture is cropped to the clip"); + assert!(differing(&png, &canvas(&s, Rect::new(200.0, 200.0, 300.0, 300.0))) < 100, "the picture matches the canvas"); +} + +#[test] +fn an_asset_on_a_hidden_layer_is_drawn_clipped() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let (square, _) = clipped(&mut s); + let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap(); + run(&mut s, "layer.setProps", json!({"id": layer, "visible": false})); + let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]})); + let img = image::load_from_memory(&b64(&r["files"][0])).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (200, 200), "cropped to the clip"); + let (centre, corner) = (img.get_pixel(100, 100), img.get_pixel(3, 3)); + assert!(centre[0] > 200 && centre[3] > 200, "the square is drawn: {centre:?}"); + assert_eq!(corner[3], 0, "outside the circle stays clear: {corner:?}"); +} + +#[test] +fn export_for_screens_and_assets_by_selection_keep_the_clip() { + let mut s = session(); + let (square, _) = clipped(&mut s); + run(&mut s, "select.set", json!({"ids": [square]})); + let r = run(&mut s, "document.exportForScreens", json!({"selection": "one", "formats": [{"format": "png", "scale": 1}, {"format": "svg"}]})); + let want = canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0)); + let png = r["files"].as_array().unwrap().iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(".png"))).unwrap(); + assert!(differing(&b64(png), &want) < 200, "Export for Screens by selection: {}", png["name"]); + let svg = r["files"].as_array().unwrap().iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(".svg"))).unwrap(); + let text = String::from_utf8(b64(svg)).unwrap(); + assert!(text.contains(" usize { + image::load_from_memory(png).unwrap().to_rgba8().pixels().filter(|p| p[0] > 200 && p[1] < 80 && p[3] > 200).count() +} + +#[test] +fn a_selection_on_a_non_printing_layer_exports_to_pdf() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let (square, _) = clipped(&mut s); + run(&mut s, "layer.setProps", json!({"id": layer, "printable": false})); + run(&mut s, "select.set", json!({"ids": [square]})); + let pdf = b64(&run(&mut s, "document.exportSelection", json!({"format": "pdf", "createLayers": false, "preserveEditing": false}))); + let mut t = Session::new(); + run(&mut t, "document.open", json!({"name": "sel.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf)})); + let page = t.doc().unwrap().doc.artboards[0].rect; + let mut png = vec![]; + canvas(&t, page).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert!(red(&png) > 20_000, "the PDF draws the square: {} red pixels", red(&png)); + assert!(differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))) < 300, "the PDF draws what the canvas draws"); +} + +#[test] +fn export_for_screens_of_layers_draws_them_as_the_canvas_does() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id.0; + clipped(&mut s); + // A layer at 50% opacity: its art is drawn at 50%. + run(&mut s, "transparency.set", json!({"ids": [layer], "opacity": 50})); + let r = run(&mut s, "document.exportForScreens", json!({"layers": [layer], "formats": [{"format": "png", "scale": 1}]})); + let png = b64(&r["files"][0]); + assert!(differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))) < 200, "the layer's opacity applies"); + // A template layer's art is still drawn. + run(&mut s, "transparency.set", json!({"ids": [layer], "opacity": 100})); + run(&mut s, "layer.setProps", json!({"id": layer, "template": true})); + let r = run(&mut s, "document.exportForScreens", json!({"layers": [layer], "formats": [{"format": "png", "scale": 1}, {"format": "svg"}]})); + let files = r["files"].as_array().unwrap(); + let file = |ext: &str| b64(files.iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(ext))).unwrap()); + assert!(red(&file(".png")) > 20_000, "the template layer's art is drawn: {} red pixels", red(&file(".png"))); + assert!(String::from_utf8(file(".svg")).unwrap().contains("` (the document's file name without its extension, or its title before it is saved; the Full Document file and the `.zip` use the same name), `` (the artboard, asset, layer or object), ``, `####` (a counter zero-padded to the run of `#`, from `counterStart`; `counterPer: "item"` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 198fdc28e..fd2771491 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -596,6 +596,8 @@ since 2026-10-01. - **Effects in Export Selection and asset crops:** File › Export Selection, Asset Export, Export for Screens' Assets tab and CSS Properties' rasterized art crop each object to the bounds the renderer measures for what it draws: drop shadows, glows, geometry effects such as Offset Path, a group's own effects and the effects in a symbol's art. A brushed path is cropped to its visual bounds, and a Shaper group is cropped to the art it shows. `assets.list` reports the same crop. Engine tests export a square with each effect, a group with a shadow and an asset with a shadow, and check that the effect is inside the image. Public reference: a [Community Expert answer](https://community.adobe.com/t5/illustrator-discussions/exporting-assets-adds-transparent-borders/m-p/10708713/highlight/true) traces the extra area of exported assets to the raster effects' area (Document Raster Effects Settings › Add Around Object). +- **Clips and masks around exported selections:** File › Export Selection, Export for Screens by selection or by layer, Asset Export and CSS Properties' rasterized art draw each object in the layers and groups that hold it. A clip group's or clipping layer's clip, an opacity mask, and a group's or layer's effects, opacity and blend mode apply as on the canvas. The crop is the bounds the renderer measures for that art, a clip group by its clip. These are VectorCraft's own rules. An object of a blend, envelope, repeat or intertwine is exported on its own, and a text export of the selection holds only the selected type. Engine tests compare the PNG exports with the canvas over the same area, reopen the SVG and PDF exports, and cover nested clip groups, symbol instances, 200 clip groups and assets in hidden or locked groups. + - **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them. - **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Flatten Transparency puts its white under the finished art the same way. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`, `tests::knockout::no_blend_mode_reads_the_background`, `tests::cmykflatten::every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk`. From 9ccea9cfcb31f1412bb9e4b03df227af7a99cb22 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 21:24:13 -0800 Subject: [PATCH 09/14] Commands: engine.apiReference returns the command API reference in Markdown engine.apiReference {} -> {markdown} writes a reference from the built-in commands' parameter schemas each time it runs: a section per command that has a schema, in registry order, with its label, menu path, API levels, what it writes, cost, summary and return, and a table of its parameters (type, range with included or excluded ends, unit, JSON or worked-out default, required, levels and description; object members as dotted rows). Commands without a schema, plug-in commands included, are left out. Schema text is escaped so it stays plain text in its cell, control characters become spaces, and a reference past 1 MiB is an error rather than a cut-short text. The command needs no document. MCP serves the same text as the fixed resource vectorcraft://commands/reference (text/markdown; charset=utf-8); the other resources still read as JSON. The command's own schema raises the schema API level to 2. Its label and its size error have rows in every catalog that needs them. Docs: docs/mcp.md, docs/target-app-parity.md. --- apps/vectorcraft-cli/tests/it/cli.rs | 2 +- crates/engine/src/cmd.rs | 3 + crates/engine/src/cmd/apiref.rs | 461 +++++++++++++++++++++++++++ crates/engine/src/schemas.rs | 10 +- crates/mcp/src/resources.rs | 76 ++++- crates/mcp/src/server.rs | 4 +- crates/mcp/src/tests.rs | 2 +- crates/mcp/tests/protocol_props.rs | 12 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/mcp.md | 31 +- docs/target-app-parity.md | 1 + 37 files changed, 635 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/cmd/apiref.rs diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index f2600d9b4..5bdd2bf63 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -597,7 +597,7 @@ fn exercise_mcp_core( assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}"); mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}})); - assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3)); + assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 4)); mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}})); assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4)); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a68703f11..7c1c9eea4 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -2,6 +2,7 @@ pub mod actionset; mod addons; +mod apiref; pub(crate) mod appearance; mod assets; mod attributes; @@ -384,6 +385,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(actionset::specs()); v.extend(importmatches::specs()); v.extend(addons::specs()); + // Last, so the reference it writes ends with its own section. + v.extend(apiref::specs()); v }) } diff --git a/crates/engine/src/cmd/apiref.rs b/crates/engine/src/cmd/apiref.rs new file mode 100644 index 000000000..54e07a1c8 --- /dev/null +++ b/crates/engine/src/cmd/apiref.rs @@ -0,0 +1,461 @@ +//! `engine.apiReference`: the command API reference in Markdown, written when it is asked for from +//! the parameter schemas of the built-in commands ([`crate::schemas`]). Each command that has a +//! schema gets a section, in registry order, with its parameters in the schema's order; a command +//! without a schema, a plug-in's included, is left out. The text depends only on the registry, so +//! every call in one build returns the same bytes. +//! +//! Text from a schema is escaped before it goes into a heading, a list item or a table cell: the +//! Markdown punctuation characters get a backslash (CommonMark 0.31.2, backslash escapes, +//! ), so a description shows as plain text and +//! cannot end a table cell or make a link, an image or HTML; line breaks, tabs and other control +//! characters become spaces. + +use serde_json::{Value, json}; +use vectorcraft_apischema::{Bound, MAX_DEPTH, MAX_ID, Omitted, Param, ParamSchema, Type, Writes}; + +use super::{CommandSpec, Result, Session, always, cmd, command_specs}; +use crate::EngineError; + +const ID: &str = "engine.apiReference"; + +/// The largest reference the command returns, in bytes of UTF-8. +pub const MAX_BYTES: usize = 1 << 20; + +static REFERENCE: ParamSchema = ParamSchema::new( + &[], + "the command API reference in Markdown, written from the built-in commands' parameter schemas: a section for each command that has one, in registry order", +) +.returns("{markdown: string}") +.writes(Writes::Nothing) +.since(2); + +pub fn specs() -> Vec { + vec![cmd!( + query "engine.apiReference", + "Command API Reference", + [], + None, + "{} the command API reference in Markdown, written from the built-in commands' parameter schemas: a section for each command that has one, in registry order → {markdown: string}", + always, + api_reference, + schema: &REFERENCE + )] +} + +fn api_reference(_: &mut Session, _: &Value) -> Result { + Ok(json!({ "markdown": reference(command_specs())? })) +} + +const HEADER: &str = "# VectorCraft command API reference\n\nThis reference lists only the commands that have a parameter schema, in registry order. `engine.commands` lists every command, with its params text and whether it can run now.\n"; + +/// The reference for the commands of `specs` that have a schema, or an error when it would be +/// larger than [`MAX_BYTES`]; a reference that is too large is never cut short. +pub(crate) fn reference(specs: &[CommandSpec]) -> Result { + let mut out = String::from(HEADER); + let mut sections = 0; + for spec in specs { + let Some(schema) = spec.schema else { continue }; + let text = section(spec, schema); + if out.len() + text.len() > MAX_BYTES { + return Err(EngineError::Other(format!("`{ID}`: the reference would be larger than {MAX_BYTES} bytes"))); + } + out.push_str(&text); + sections += 1; + } + if sections == 0 { + out.push_str("\nNo commands have parameter schemas.\n"); + } + Ok(out) +} + +/// One command's section: its heading, a list of what it is, and its parameters. +fn section(spec: &CommandSpec, schema: &ParamSchema) -> String { + let menu = if spec.menu.is_empty() { "None".to_string() } else { spec.menu.iter().map(|m| escape(m)).collect::>().join(" › ") }; + let mut s = format!("\n## {}\n\n", escape(spec.id)); + s.push_str(&format!("- Label: {}\n", escape(spec.label))); + s.push_str(&format!("- Menu: {menu}\n")); + s.push_str(&format!("- API level: {}\n", schema.since)); + s.push_str(&format!("- Deprecated: {}\n", level_text(schema.deprecated))); + s.push_str(&format!("- Writes: {}\n", writes_text(schema.writes))); + s.push_str(&format!("- Cost: {}\n", schema.cost)); + s.push_str(&format!("- Summary: {}\n", text_or_none(schema.summary))); + s.push_str(&format!("- Returns: {}\n\n", text_or_none(schema.returns))); + if schema.params.is_empty() { + s.push_str("No parameters.\n"); + return s; + } + s.push_str("| Parameter | Type | Range | Unit | Default | Required | Since | Deprecated | Description |\n"); + s.push_str("|---|---|---|---|---|---|---|---|---|\n"); + rows("", schema.params, 0, &mut s); + s +} + +/// A table row for each of `params`, and after each one the rows of the members of an object it +/// holds: `edits[].name` for an object in a list, `options.size` for an object. +fn rows(prefix: &str, params: &[Param], depth: usize, out: &mut String) { + for p in params { + let name = if prefix.is_empty() { p.name.to_string() } else { format!("{prefix}.{}", p.name) }; + let (range, unit) = range_and_unit(&p.ty); + out.push_str(&format!( + "| {} | {} | {range} | {unit} | {} | {} | {} | {} | {} |\n", + escape(&name), + type_text(&p.ty, depth), + default_text(p.omitted), + if p.required { "Yes" } else { "No" }, + p.since, + level_text(p.deprecated), + description(p), + )); + if depth < MAX_DEPTH + && let Some((members, path)) = members(&p.ty, name, depth) + { + rows(&path, members, depth + 1, out); + } + } +} + +/// The parameters of the object `ty` holds, directly or as the items of lists, with the path of +/// its members' prefix (`name`, `name[]`, `name[][]`). +fn members(ty: &Type, mut path: String, depth: usize) -> Option<(&'static [Param], String)> { + let mut ty = ty; + for _ in depth..MAX_DEPTH { + match ty { + Type::Object(params) => return Some((params, path)), + Type::Array(item) => { + path.push_str("[]"); + ty = item; + } + _ => return None, + } + } + None +} + +fn type_text(ty: &Type, depth: usize) -> String { + match ty { + Type::Number { .. } => "number".into(), + Type::Integer { .. } => "integer".into(), + Type::Boolean => "boolean".into(), + Type::String => "string".into(), + Type::Enum(names) => format!("one of {}", names.iter().map(|n| format!("\"{}\"", escape(n))).collect::>().join(", ")), + Type::Id => "object id (integer)".into(), + Type::Ids => "list of object ids".into(), + Type::Color => escape("color (a string, [r, g, b] or an object)"), + Type::Point => escape("point ([x, y], two numbers)"), + Type::Object(_) => "object".into(), + Type::Array(_) if depth >= MAX_DEPTH => "list".into(), + Type::Array(item) => format!("list of {}", type_text(item, depth + 1)), + } +} + +/// The range and unit cells of a value of type `ty`. +fn range_and_unit(ty: &Type) -> (String, String) { + let ends = |low: Option, high: Option| { + let both: Vec = low.into_iter().chain(high).collect(); + if both.is_empty() { "None".to_string() } else { both.join(", ") } + }; + match ty { + Type::Number { min, max, unit } => { + let low = min.map(|b| match b { + Bound::Inclusive(a) => format!("at least {a}"), + Bound::Exclusive(a) => format!("more than {a}"), + }); + let high = max.map(|b| match b { + Bound::Inclusive(a) => format!("at most {a}"), + Bound::Exclusive(a) => format!("less than {a}"), + }); + (ends(low, high), unit.map_or("None".to_string(), escape)) + } + Type::Integer { min, max } => (ends(min.map(|a| format!("at least {a}")), max.map(|a| format!("at most {a}"))), "None".into()), + Type::Id => (format!("at least 0, at most {MAX_ID}"), "None".into()), + _ => ("None".into(), "None".into()), + } +} + +fn default_text(omitted: Omitted) -> String { + match omitted { + Omitted::Nothing => "None".into(), + Omitted::Value(json) => format!("{} (JSON)", escape(json)), + Omitted::Note(note) => format!("{} (worked out by the command)", escape(note)), + } +} + +/// The parameter's doc text, and what the schema says about how its value is checked. +fn description(p: &Param) -> String { + let mut parts = Vec::new(); + if !p.doc.is_empty() { + parts.push(escape(p.doc)); + } + if p.null_omitted { + parts.push("null reads as omitted".to_string()); + } + if p.lenient { + parts.push("lenient (a value of another type or out of range is passed to the command, not refused)".to_string()); + } + if parts.is_empty() { "None".to_string() } else { parts.join("; ") } +} + +fn level_text(level: Option) -> String { + level.map_or("None".to_string(), |l| format!("API level {l}")) +} + +fn writes_text(writes: Writes) -> &'static str { + match writes { + Writes::Nothing => "nothing", + Writes::Session => "session", + Writes::Preferences => "preferences", + Writes::Document => "document", + } +} + +fn text_or_none(text: &str) -> String { + if text.is_empty() { "None".to_string() } else { escape(text) } +} + +/// The characters [`escape`] puts a backslash before. +const ESCAPED: &[char] = &['\\', '|', '[', ']', '<', '>', '`', '*', '_', '!', '&', '#', '~', ':', '@']; + +/// `text` as plain Markdown text on one line: each of [`ESCAPED`] with a backslash before it, the +/// `.` after `www` too (so no autolink starts), and each control character as a space. +pub(crate) fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + if c.is_control() { + out.push(' '); + continue; + } + let after_www = out.len().checked_sub(3).and_then(|at| out.as_bytes().get(at..)).is_some_and(|b| b.eq_ignore_ascii_case(b"www")); + if ESCAPED.contains(&c) || (c == '.' && after_www) { + out.push('\\'); + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(_: &mut Session, _: &Value) -> Result { + Ok(Value::Null) + } + + fn spec(id: &'static str, menu: &'static [&'static str], schema: Option<&'static ParamSchema>) -> CommandSpec { + CommandSpec { id, label: "Label", menu, shortcut: None, params: "", enabled: always, run, journal: false, schema } + } + + /// The cells of a table row as a GFM table reads them: split at each `|` that has no backslash + /// before it, with the outer pipes and the spaces around each cell taken off. + fn cells(row: &str) -> Vec { + let inner = row.strip_prefix('|').and_then(|r| r.strip_suffix('|')).unwrap_or_else(|| panic!("not a table row: {row}")); + let (mut out, mut cell, mut chars) = (Vec::new(), String::new(), inner.chars()); + while let Some(c) = chars.next() { + match c { + '\\' => { + cell.push(c); + cell.extend(chars.next()); + } + '|' => out.push(std::mem::take(&mut cell).trim().to_string()), + _ => cell.push(c), + } + } + out.push(cell.trim().to_string()); + out + } + + /// What `text` shows as: each escaped character as itself. Panics on a Markdown punctuation + /// character without its backslash, or a backslash before a character that needs none. + fn literal(text: &str) -> String { + let (mut out, mut chars) = (String::new(), text.chars()); + while let Some(c) = chars.next() { + assert!(!ESCAPED.contains(&c) || c == '\\', "`{c}` is not escaped in {text}"); + if c == '\\' { + let next = chars.next().unwrap_or_else(|| panic!("a backslash ends {text}")); + assert!(ESCAPED.contains(&next) || next == '.', "`\\{next}` in {text}"); + out.push(next); + } else { + out.push(c); + } + } + out + } + + /// The shown cells of the row of parameter `name`. + fn row(markdown: &str, name: &str) -> Vec { + let found = markdown.lines().filter(|l| l.starts_with('|')).find(|l| cells(l).first().map(|c| literal(c)).as_deref() == Some(name)); + let row = found.unwrap_or_else(|| panic!("no row for {name} in\n{markdown}")); + let cells = cells(row); + assert_eq!(cells.len(), 9, "{row}"); + cells.iter().map(|c| literal(c)).collect() + } + + /// The text after `- {label}: ` in the section's list. + fn item(markdown: &str, label: &str) -> String { + let prefix = format!("- {label}: "); + let line = markdown.lines().find_map(|l| l.strip_prefix(prefix.as_str())); + literal(line.unwrap_or_else(|| panic!("no {label} in\n{markdown}"))) + } + + /// Two calls give the same bytes, with no document open, and the reference has one section + /// for each built-in command that has a schema, in registry order, ending with this command's. + #[test] + fn the_reference_lists_each_schema_once_in_registry_order_and_is_the_same_each_time() { + let mut s = Session::new(); + let first = s.execute(ID, &json!({})).unwrap(); + let second = s.execute(ID, &Value::Null).unwrap(); + assert_eq!(first, second); + let markdown = first["markdown"].as_str().unwrap(); + assert_eq!(markdown, reference(command_specs()).unwrap()); + assert!(markdown.len() < MAX_BYTES / 10, "{} bytes", markdown.len()); + let headings: Vec = markdown.lines().filter_map(|l| l.strip_prefix("## ")).map(literal).collect(); + let expected: Vec<&str> = command_specs().iter().filter(|c| c.schema.is_some()).map(|c| c.id).collect(); + assert_eq!(headings, expected); + assert_eq!(headings.last().map(String::as_str), Some(ID)); + assert!(expected.contains(&"view.saved.new") && !headings.iter().any(|h| h == "file.new")); + let new_view = &markdown[markdown.find("## view.saved.new").unwrap()..]; + let names: Vec = new_view + .lines() + .skip_while(|l| !l.starts_with("|---")) + .skip(1) + .take_while(|l| l.starts_with('|')) + .map(|l| literal(&cells(l)[0])) + .collect(); + assert_eq!(names, ["name", "center", "zoom", "rotation"]); + let edit = &markdown[markdown.find("## view.saved.edit").unwrap()..]; + assert_eq!(row(edit, "edits[].newName")[8], "null reads as omitted"); + assert_eq!(row(edit, "newName")[8], "rename; null reads as omitted"); + } + + const INNER: &[Param] = + &[Param::new("size", Type::Integer { min: Some(1), max: Some(10) }), Param::new("mode", Type::Enum(&["a", "b"])).defaults_to("\"a\"")]; + + static FULL: ParamSchema = ParamSchema::new( + &[ + Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(64.0)), unit: Some("pt") }) + .doc("1 = 100%"), + Param::new("name", Type::String).default_note("the first free name").null_omitted(), + Param::new("old", Type::Boolean).defaults_to("true").since(2).deprecated(3).lenient(), + Param::new("edits", Type::Array(&Type::Object(INNER))).optional(), + Param::new("ids", Type::Ids).optional(), + Param::new("at", Type::Point).optional(), + Param::new("color", Type::Color).optional(), + Param::new("id", Type::Id).optional(), + Param::new("ratio", Type::Number { min: Some(Bound::Inclusive(-1.5)), max: Some(Bound::Exclusive(2.0)), unit: None }).optional(), + ], + "do the thing", + ) + .returns("{done}") + .writes(Writes::Preferences) + .since(2) + .deprecated(4) + .cost(5); + + /// Each field of a schema shows, nested members in their order under dotted names. + #[test] + fn every_field_of_a_schema_is_shown() { + let markdown = reference(&[spec("test.full", &["View", "Saved"], Some(&FULL))]).unwrap(); + for (label, shown) in [ + ("Label", "Label"), + ("Menu", "View › Saved"), + ("API level", "2"), + ("Deprecated", "API level 4"), + ("Writes", "preferences"), + ("Cost", "5"), + ("Summary", "do the thing"), + ("Returns", "{done}"), + ] { + assert_eq!(item(&markdown, label), shown, "{label}"); + } + let lenient = "lenient (a value of another type or out of range is passed to the command, not refused)"; + let expected: [[&str; 9]; 11] = [ + ["zoom", "number", "more than 0, at most 64", "pt", "None", "Yes", "1", "None", "1 = 100%"], + ["name", "string", "None", "None", "the first free name (worked out by the command)", "No", "1", "None", "null reads as omitted"], + ["old", "boolean", "None", "None", "true (JSON)", "No", "2", "API level 3", lenient], + ["edits", "list of object", "None", "None", "None", "No", "1", "None", "None"], + ["edits[].size", "integer", "at least 1, at most 10", "None", "None", "Yes", "1", "None", "None"], + ["edits[].mode", "one of \"a\", \"b\"", "None", "None", "\"a\" (JSON)", "No", "1", "None", "None"], + ["ids", "list of object ids", "None", "None", "None", "No", "1", "None", "None"], + ["at", "point ([x, y], two numbers)", "None", "None", "None", "No", "1", "None", "None"], + ["color", "color (a string, [r, g, b] or an object)", "None", "None", "None", "No", "1", "None", "None"], + ["id", "object id (integer)", "at least 0, at most 9007199254740991", "None", "None", "No", "1", "None", "None"], + ["ratio", "number", "at least -1.5, less than 2", "None", "None", "No", "1", "None", "None"], + ]; + let names: Vec = + markdown.lines().filter(|l| l.starts_with("| ") && !l.starts_with("| Parameter")).map(|l| literal(&cells(l)[0])).collect(); + assert_eq!(names, expected.map(|r| r[0])); + for want in expected { + assert_eq!(row(&markdown, want[0]), want, "{}", want[0]); + } + } + + const HOSTILE: &str = "a | b [link](https://evil.example) `code` \\ *em* _u_ ![img](x) & ~~s~~ # h www.example.com me@example.com\r\nnext\tline"; + + static ESCAPES: ParamSchema = + ParamSchema::new(&[Param::new("a|b", Type::String).doc(HOSTILE).defaults_to("\"|\"")], HOSTILE).returns(HOSTILE); + + /// Markdown in a schema's text shows as plain text: a description stays in its own cell of a + /// nine-cell row, every punctuation character is escaped, and line breaks and tabs are spaces. + #[test] + fn markdown_in_schema_text_stays_literal_in_its_cell() { + let markdown = reference(&[spec("test.escapes", &["A|B", "[x](y)"], Some(&ESCAPES))]).unwrap(); + let shown = HOSTILE.replace(['\r', '\n', '\t'], " "); + let cells = row(&markdown, "a|b"); + assert_eq!(cells[4], "\"|\" (JSON)"); + assert_eq!(cells[8], shown); + assert_eq!(item(&markdown, "Summary"), shown); + assert_eq!(item(&markdown, "Returns"), shown); + assert_eq!(item(&markdown, "Menu"), "A|B › [x](y)"); + assert!(markdown.contains("www\\.example"), "{markdown}"); + let table: Vec<&str> = markdown.lines().filter(|l| l.starts_with('|')).collect(); + assert_eq!(table.len(), 3, "the header, the delimiter row and one row: {table:?}"); + assert!(!markdown.contains('\r') && !markdown.contains('\t')); + } + + /// A registry with no schema gives the header and a line that says so; a command without a + /// schema adds nothing. + #[test] + fn a_registry_without_schemas_gives_a_document_that_says_so() { + let empty = format!("{HEADER}\nNo commands have parameter schemas.\n"); + assert_eq!(reference(&[]).unwrap(), empty); + assert_eq!(reference(&[spec("test.none", &["View"], None)]).unwrap(), empty); + let one = reference(&[spec("test.none", &[], None), spec("test.full", &[], Some(&FULL)), spec("test.other", &[], None)]).unwrap(); + assert_eq!(one, reference(&[spec("test.full", &[], Some(&FULL))]).unwrap()); + } + + /// A reference of exactly [`MAX_BYTES`] is returned; one byte more is an error naming the + /// command, with no part of the reference. + #[test] + fn a_reference_past_the_limit_is_an_error() { + let with = |summary: String| -> Result { + let schema: &'static ParamSchema = Box::leak(Box::new(ParamSchema::new(&[], Box::leak(summary.into_boxed_str())))); + reference(&[spec("test.big", &[], Some(schema))]) + }; + let base = with("x".into()).unwrap().len(); + let fits = with("x".repeat(MAX_BYTES - base + 1)).unwrap(); + assert_eq!(fits.len(), MAX_BYTES); + let e = with("x".repeat(MAX_BYTES - base + 2)).unwrap_err().to_string(); + assert!(e.contains(ID) && e.contains("larger than 1048576 bytes"), "{e}"); + assert!(e.len() < 200, "{e}"); + } + + /// An unknown key is refused with a parameter error naming it, and the command needs no + /// document. + #[test] + fn an_unknown_parameter_is_refused_naming_it() { + let mut s = Session::new(); + let e = s.execute(ID, &json!({"prefix": "view."})).unwrap_err().to_string(); + assert!(e.contains(ID) && e.contains("`prefix`"), "{e}"); + assert!(s.execute(ID, &json!([])).is_err()); + assert!(s.active().is_none()); + assert!(s.execute(ID, &json!({})).is_ok()); + } + + #[test] + fn escape_marks_each_punctuation_character_and_flattens_control_characters() { + assert_eq!(escape("a|b"), "a\\|b"); + assert_eq!(escape("WWW.x and w.x"), "WWW\\.x and w.x"); + assert_eq!(escape("é\u{7}\u{85}z"), "é z"); + assert_eq!(escape("1.5 > 1"), "1.5 \\> 1"); + } +} diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index a24613a07..61ec8f0fd 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -26,10 +26,10 @@ use crate::cmd::{CommandSpec, bad, command_specs}; use crate::{EngineError, Result}; /// The API level of the commands' parameters. -pub const API_VERSION: u32 = 1; +pub const API_VERSION: u32 = 2; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. -pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c)]; +pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653)]; /// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { @@ -296,8 +296,12 @@ mod tests { ("view.capsLock", json!([])), ("view.saved.new", json!(5)), ("view.transparencyGrid", json!("on")), + ("engine.apiReference", json!({})), + ("engine.apiReference", json!(null)), + ("engine.apiReference", json!({"prefix": "view."})), + ("engine.apiReference", json!([])), ]; - assert_eq!(schemas().len(), 6); + assert_eq!(schemas().len(), 7); for (id, schema) in schemas() { let written = schema.to_json(); check_schema(&written, id).unwrap_or_else(|e| panic!("{e}\n{written:#}")); diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index 50792de6d..9f2f3b99e 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -18,6 +18,20 @@ pub const DOC_JSON_URI: &str = "vectorcraft://document/json"; /// The live command catalog, including enabled state. pub const COMMANDS_URI: &str = "vectorcraft://commands"; +/// The command API reference in Markdown (`engine.apiReference`). +pub const REFERENCE_URI: &str = "vectorcraft://commands/reference"; + +/// The media type of the JSON resources. +pub const JSON: &str = "application/json"; +/// The media type of the command API reference. +pub const MARKDOWN: &str = "text/markdown; charset=utf-8"; + +/// What `resources/read` returns for a URI: the text and its media type. +#[derive(Debug, PartialEq)] +pub struct Contents { + pub mime: &'static str, + pub text: String, +} /// Why a resource could not be read. The distinction matters: the server turns it into `-32002` /// (no such resource) or `-32602` (bad URI), and guessing from the message text would not hold. @@ -83,9 +97,24 @@ pub fn list() -> Value { {"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"}, {"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"}, {"uri": COMMANDS_URI, "name": "commands", "title": "Command catalog", "description": "Command ids, labels, parameter descriptions and current enabled state", "mimeType": "application/json"}, + {"uri": REFERENCE_URI, "name": "command-reference", "title": "Command API reference", "description": "Markdown reference of the commands that have a parameter schema: types, ranges, units, defaults, API levels, what each writes and returns (engine.apiReference)", "mimeType": MARKDOWN}, ]}) } +/// `resources/read`: the text behind a resource URI, the command API reference as Markdown and +/// every other resource as indented JSON ([`read`]). +pub fn read_contents(b: &mut dyn Backend, uri: &str) -> Result { + if uri == REFERENCE_URI { + let mut reply = exec(b, "engine.apiReference").map_err(ReadError::Backend)?; + return match reply.get_mut("markdown").map(Value::take) { + Some(Value::String(text)) => Ok(Contents { mime: MARKDOWN, text }), + _ => Err(ReadError::Backend("engine.apiReference returned no `markdown` text".into())), + }; + } + let v = read(b, uri)?; + Ok(Contents { mime: JSON, text: serde_json::to_string_pretty(&v).unwrap_or_default() }) +} + /// `resources/templates/list`. pub fn templates() -> Value { let list: Vec = TEMPLATES @@ -246,7 +275,7 @@ mod tests { #[test] fn lists_are_well_formed() { let l = list(); - assert_eq!(l["resources"].as_array().map(Vec::len), Some(3)); + assert_eq!(l["resources"].as_array().map(Vec::len), Some(4)); let t = templates(); let items = t["resourceTemplates"].as_array().expect("templates"); assert_eq!(items.len(), TEMPLATES.len()); @@ -257,6 +286,51 @@ mod tests { } } + /// A backend that gives every call the same reply. + struct Fixed(Result); + + impl Backend for Fixed { + fn call(&mut self, _: &str, _: Value) -> Result { + self.0.clone() + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "fixed".into() + } + } + + /// The command reference is listed and read as Markdown, with the text `engine.apiReference` + /// returns, and with no document open; the other fixed resources still read as JSON. + #[test] + fn the_command_reference_reads_as_markdown() { + let listed = list(); + let entry = listed["resources"].as_array().and_then(|r| r.iter().find(|r| r["uri"] == REFERENCE_URI)).expect("listed"); + assert_eq!(entry["mimeType"], MARKDOWN); + assert_eq!(entry["name"], "command-reference"); + let mut b = Box::new(crate::Headless::new()); + let read = read_contents(b.as_mut(), REFERENCE_URI).unwrap(); + assert_eq!(read.mime, MARKDOWN); + let direct = b.call("engine.execute", json!({"command": "engine.apiReference", "params": {}})).unwrap(); + assert_eq!(read.text, direct["markdown"].as_str().unwrap()); + assert!(read.text.starts_with("# VectorCraft command API reference\n"), "{}", read.text); + assert_eq!(read_contents(b.as_mut(), REFERENCE_URI).unwrap(), read); + let commands = read_contents(b.as_mut(), COMMANDS_URI).unwrap(); + assert_eq!(commands.mime, JSON); + assert!(serde_json::from_str::(&commands.text).unwrap().is_array()); + assert!(matches!(read_contents(b.as_mut(), "vectorcraft://commands/nope"), Err(ReadError::NotFound(_)))); + } + + /// A reply without the Markdown text, and an app that cannot be reached, are backend errors. + #[test] + fn a_bad_or_failed_reference_reply_is_a_backend_error() { + for reply in [Ok(json!({})), Ok(json!({"markdown": 5})), Ok(json!("text")), Err("the app is not running".to_string())] { + let mut b = Fixed(reply.clone()); + assert!(matches!(read_contents(&mut b, REFERENCE_URI), Err(ReadError::Backend(_))), "{reply:?}"); + } + } + #[test] fn missing_and_unknown_uris_are_errors() { let mut b = Box::new(crate::Headless::with_document()); diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index c0139bccc..b1cfd3087 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -439,12 +439,12 @@ impl Server { } "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; - let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e { + let read = resources::read_contents(self.backend.as_mut(), uri).map_err(|e| match e { resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")), resources::ReadError::Invalid(why) => (INVALID_PARAMS, why), resources::ReadError::Backend(why) => (INTERNAL_ERROR, why), })?; - Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) + Ok(json!({"contents": [{"uri": uri, "mimeType": read.mime, "text": read.text}]})) } "completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)), "logging/setLevel" => { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index e986deda0..c07aa9efc 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -304,7 +304,7 @@ fn headless_end_to_end() { // Resources. let v = rpc(&mut s, 10, "resources/list", json!({})); - assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 3); + assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 4); let v = rpc(&mut s, 11, "resources/read", json!({"uri": "vectorcraft://document"})); let text = v["result"]["contents"][0]["text"].as_str().unwrap(); assert!(serde_json::from_str::(text).unwrap()["layers"].is_array()); diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index 2eb3db647..f9dc065ff 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -290,8 +290,16 @@ fn resources_are_json() { let list = rpc(&mut s, 2, "resources/list", json!({})); for r in list["result"]["resources"].as_array().unwrap() { let v = rpc(&mut s, 3, "resources/read", json!({"uri": r["uri"]})); - let text = v["result"]["contents"][0]["text"].as_str().unwrap(); - serde_json::from_str::(text).unwrap(); + let content = &v["result"]["contents"][0]; + assert_eq!(content["mimeType"], r["mimeType"], "{}", r["uri"]); + assert_eq!(content["uri"], r["uri"]); + let text = content["text"].as_str().unwrap(); + if r["mimeType"] == "application/json" { + serde_json::from_str::(text).unwrap(); + } else { + assert_eq!(r["mimeType"], "text/markdown; charset=utf-8", "{}", r["uri"]); + assert!(text.starts_with("# ") && text.ends_with('\n'), "{}", r["uri"]); + } } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d58bda3aa..1f3d5dc90 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -567,6 +567,7 @@ font-technology Variable Променливи Coming soon — tracked in the parity plan Очаквайте скоро — следи се в плана за съвместимост Command Click to Select Objects Behind Щракване с Command за избор на обекти отзад Command Dialog Диалогов прозорец за команда + Command API Reference Справочник за API на командите Community Общност Compatibility: Съвместимост: Compatible Gradient and Gradient Mesh Printing Съвместим печат на градиент и градиентна мрежа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4d57ec80b..b432ce9dd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -965,6 +965,7 @@ lighting Light Svjetlo Color Picker… Birač boja… Coming soon — tracked in the parity plan Uskoro — prati se u planu usklađenosti Command Dialog Dijalog naredbe + Command API Reference Referenca API-ja naredbi Copy Selected Style Kopiraj odabrani stil Corners… Uglovi… Define Perspective Grid… Definiši mrežu perspektive… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5ed856ac9..b00834c4e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -794,6 +794,7 @@ antiAlias Sharp Nítid Command Ordre Command Click to Select Objects Behind Clic amb Ordre per seleccionar els objectes del darrere Command Dialog Diàleg d’ordres + Command API Reference Referència de l’API d’ordres Comment not closed Comentari no tancat Commenting, filling in form fields and signing Comentar, emplenar camps de formulari i signar Commits Commits @@ -5432,6 +5433,7 @@ digits Malayalam Malaiàlam @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos @msg not an action set file no és un fitxer de conjunt d’accions @msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referència superaria els {MAX_BYTES} bytes @msg the action set file holds more than 1,000 actions el fitxer de conjunt d’accions conté més de 1.000 accions @msg the action set file needs a later version of VectorCraft el fitxer de conjunt d’accions necessita una versió més recent del VectorCraft @msg an action in the action set file has more than 10,000 steps una acció del fitxer de conjunt d’accions té més de 10.000 passos diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5792e9ca1..bfa4fc62e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -985,6 +985,7 @@ lighting Light Světlo Color Picker… Výběr barvy… Coming soon — tracked in the parity plan Připravujeme — sledováno v plánu parity Command Dialog Dialogové okno příkazu + Command API Reference Reference API příkazů Copy Selected Style Kopírovat vybraný styl Corners… Rohy… Define Perspective Grid… Definovat perspektivní mřížku… @@ -4722,6 +4723,7 @@ imageTrace Outline Obrys @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků @msg not an action set file není to soubor sady akcí @msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: reference by byla větší než {MAX_BYTES} bajtů @msg the action set file holds more than 1,000 actions soubor sady akcí obsahuje více než 1 000 akcí @msg the action set file needs a later version of VectorCraft soubor sady akcí vyžaduje novější verzi aplikace VectorCraft @msg an action in the action set file has more than 10,000 steps akce v souboru sady akcí má více než 10 000 kroků diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bf2efab42..ceb4dc632 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -906,6 +906,7 @@ lighting Light Lys Color Picker… Farvevælger… Coming soon — tracked in the parity plan Kommer snart — følges i paritetsplanen Command Dialog Kommandodialog + Command API Reference API-reference for kommandoer Copy Selected Style Kopiér markeret typografi Corners… Hjørner… Define Perspective Grid… Definér perspektivgitter… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 37a1b3b44..a8a2e3e05 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -560,6 +560,7 @@ font-technology Variable Variabel Coming soon — tracked in the parity plan Demnächst verfügbar – im Paritätsplan vermerkt Command Click to Select Objects Behind Cmd-Klick wählt dahinterliegende Objekte aus Command Dialog Befehlsdialog + Command API Reference API-Referenz der Befehle Community Community Compatibility: Kompatibilität: Compatible Gradient and Gradient Mesh Printing Kompatibles Drucken von Verläufen und Verlaufsgittern @@ -4329,6 +4330,7 @@ theme Appearance Erscheinungsbild @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen @msg not an action set file keine Aktionssatzdatei @msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: die Referenz wäre größer als {MAX_BYTES} Byte @msg the action set file holds more than 1,000 actions die Aktionssatzdatei enthält mehr als 1.000 Aktionen @msg the action set file needs a later version of VectorCraft die Aktionssatzdatei erfordert eine neuere Version von VectorCraft @msg an action in the action set file has more than 10,000 steps eine Aktion in der Aktionssatzdatei hat mehr als 10.000 Schritte diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 25baaaa17..30e5675bb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -542,6 +542,7 @@ font-technology Variable Μεταβλητές Coming soon — tracked in the parity plan Προσεχώς — περιλαμβάνεται στο σχέδιο αντιστοιχίας λειτουργιών Command Click to Select Objects Behind Command-κλικ για επιλογή αντικειμένων από πίσω Command Dialog Παράθυρο εντολών + Command API Reference Αναφορά API εντολών Community Κοινότητα Compatibility: Συμβατότητα: Compatible Gradient and Gradient Mesh Printing Συμβατή εκτύπωση ντεγκραντέ και πλέγματος ντεγκραντέ @@ -4367,6 +4368,7 @@ digits Malayalam Μαλαγιάλαμ @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα @msg not an action set file δεν είναι αρχείο συνόλου ενεργειών @msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: η αναφορά θα ήταν μεγαλύτερη από {MAX_BYTES} byte @msg the action set file holds more than 1,000 actions το αρχείο συνόλου ενεργειών περιέχει περισσότερες από 1.000 ενέργειες @msg the action set file needs a later version of VectorCraft το αρχείο συνόλου ενεργειών απαιτεί νεότερη έκδοση του VectorCraft @msg an action in the action set file has more than 10,000 steps μια ενέργεια στο αρχείο συνόλου ενεργειών έχει περισσότερα από 10.000 βήματα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dca929ff2..4c12636f9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -582,6 +582,7 @@ font-technology Variable Variables Coming soon — tracked in the parity plan Próximamente: incluido en el plan de paridad Command Click to Select Objects Behind Cmd+clic para seleccionar objetos de detrás Command Dialog Cuadro de diálogo de comando + Command API Reference Referencia de la API de comandos Community Comunidad Compatibility: Compatibilidad: Compatible Gradient and Gradient Mesh Printing Impresión compatible de degradados y mallas de degradado @@ -4413,6 +4414,7 @@ axis Both Ambos @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos @msg not an action set file no es un archivo de conjunto de acciones @msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referencia superaría los {MAX_BYTES} bytes @msg the action set file holds more than 1,000 actions el archivo de conjunto de acciones contiene más de 1000 acciones @msg the action set file needs a later version of VectorCraft el archivo de conjunto de acciones necesita una versión más reciente de VectorCraft @msg an action in the action set file has more than 10,000 steps una acción del archivo de conjunto de acciones tiene más de 10 000 pasos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1a1058dd9..e9bb13d60 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -964,6 +964,7 @@ lighting Light Valo Color Picker… Värivalitsin… Coming soon — tracked in the parity plan Tulossa pian — seurataan yhdenmukaisuussuunnitelmassa Command Dialog Komentovalintaikkuna + Command API Reference Komentojen API-viite Copy Selected Style Kopioi valittu tyyli Corners… Kulmat… Define Perspective Grid… Määritä perspektiiviruudukko… @@ -4630,6 +4631,7 @@ imageTrace Outline Ääriviiva @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta @msg not an action set file ei ole toimintosarjatiedosto @msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: viite olisi suurempi kuin {MAX_BYTES} tavua @msg the action set file holds more than 1,000 actions toimintosarjatiedostossa on yli 1 000 toimintoa @msg the action set file needs a later version of VectorCraft toimintosarjatiedosto vaatii uudemman VectorCraft-version @msg an action in the action set file has more than 10,000 steps toimintosarjatiedoston toiminnossa on yli 10 000 vaihetta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3b28b43f2..ddbcf4d19 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -583,6 +583,7 @@ font-technology Variable Variables Coming soon — tracked in the parity plan Bientôt disponible — prévu dans le plan de parité Command Click to Select Objects Behind Cmd+clic pour sélectionner les objets situés derrière Command Dialog Boîte de dialogue des commandes + Command API Reference Référence de l’API des commandes Community Communauté Compatibility: Compatibilité : Compatible Gradient and Gradient Mesh Printing Impression compatible des dégradés et des filets de dégradé @@ -4402,6 +4403,7 @@ axis Both Les deux @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées @msg not an action set file ce n’est pas un fichier de jeu de scripts @msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}` : la référence dépasserait {MAX_BYTES} octets @msg the action set file holds more than 1,000 actions le fichier de jeu de scripts contient plus de 1 000 scripts @msg the action set file needs a later version of VectorCraft le fichier de jeu de scripts nécessite une version plus récente de VectorCraft @msg an action in the action set file has more than 10,000 steps un script du fichier de jeu de scripts compte plus de 10 000 étapes diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 4e9cd6f41..5971e539f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -896,6 +896,7 @@ antiAlias Sharp शार्प Command कमांड Command Click to Select Objects Behind कमांड पीछे ऑब्जेक्ट्स का सिलेक्ट करने के लिए क्लिक करें Command Dialog कमान डायलॉग + Command API Reference कमांड API संदर्भ Comment not closed टिप्पणी बंद नहीं Commenting, filling in form fields and signing टिप्पणी करना, फॉर्म फ़ील्ड में भरना और हस्ताक्षर करना Commits कमिट्स @@ -7385,6 +7386,7 @@ antiAlias Sharp शार्प @msg the action has no steps कार्रवाई में कोई कदम नहीं है @msg the action set file holds more than 1,000 actions एक्शन सेट फ़ाइल 1,000 से अधिक कार्यों को रखती है @msg the action set file is larger than 8 MiB एक्शन सेट फ़ाइल 8 MiB से बड़ा है +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: संदर्भ {MAX_BYTES} बाइट से बड़ा होगा @msg the action set file needs a later version of VectorCraft एक्शन सेट फ़ाइल को वेक्टर क्रेफ्ट के बाद के संस्करण की आवश्यकता होती है @msg the action set ids are not a list कार्रवाई सेट ids एक सूची नहीं हैं @msg the action sets changed; nothing was deleted कार्रवाई सेट बदल गया; कुछ भी नहीं हटा दिया गया था diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8ed5bc017..578339347 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -760,6 +760,7 @@ font-technology Variable Változó Command Parancs Command Click to Select Objects Behind Cmd+kattintás a mögöttes objektumok kijelöléséhez Command Dialog Parancs párbeszédablaka + Command API Reference Parancsok API-referenciája Commenting, filling in form fields and signing Megjegyzések, űrlapmezők kitöltése és aláírás Commits Commitok Community Közösség @@ -5095,6 +5096,7 @@ imageTrace Outline Körvonal @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n} @msg not an action set file nem műveletkészlet-fájl @msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referencia nagyobb lenne {MAX_BYTES} bájtnál @msg the action set file holds more than 1,000 actions a műveletkészlet-fájl több mint 1000 műveletet tartalmaz @msg the action set file needs a later version of VectorCraft a műveletkészlet-fájlhoz a VectorCraft újabb verziója kell @msg an action in the action set file has more than 10,000 steps a műveletkészlet-fájl egyik műveletében több mint 10 000 lépés van diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 459e32a60..05d08d4bd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -906,6 +906,7 @@ lighting Light Cahaya Color Picker… Pemilih warna… Coming soon — tracked in the parity plan Segera hadir — dilacak dalam rencana paritas Command Dialog Dialog perintah + Command API Reference Referensi API perintah Copy Selected Style Salin gaya terpilih Corners… Sudut… Define Perspective Grid… Definisikan kisi perspektif… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d08128427..f225c4d5a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -582,6 +582,7 @@ font-technology Variable Variabili Coming soon — tracked in the parity plan Prossimamente — previsto nel piano di parità Command Click to Select Objects Behind Cmd+clic per selezionare gli oggetti retrostanti Command Dialog Finestra dei comandi + Command API Reference Riferimento API dei comandi Community Comunità Compatibility: Compatibilità: Compatible Gradient and Gradient Mesh Printing Stampa compatibile di sfumature e trame sfumatura @@ -4455,6 +4456,7 @@ axis Both Entrambi @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi @msg not an action set file non è un file di set di azioni @msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: il riferimento supererebbe {MAX_BYTES} byte @msg the action set file holds more than 1,000 actions il file del set di azioni contiene più di 1.000 azioni @msg the action set file needs a later version of VectorCraft il file del set di azioni richiede una versione più recente di VectorCraft @msg an action in the action set file has more than 10,000 steps un’azione nel file del set di azioni ha più di 10.000 passaggi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 11d1b5ced..3d176d839 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1253,6 +1253,7 @@ antiAlias Sharp シャープ Coming soon — tracked in the parity plan 近日対応予定 Command Click to Select Objects Behind Command+クリックで背面のオブジェクトを選択 Command Dialog コマンドダイアログ + Command API Reference コマンド API リファレンス Community コミュニティ Compatibility: 互換性: Compatible Gradient and Gradient Mesh Printing 互換性のあるグラデーションとグラデーションメッシュのプリント @@ -4801,6 +4802,7 @@ swatch Bright Violet ビビッドバイオレット @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました @msg not an action set file アクションセットファイルではありません @msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: リファレンスが {MAX_BYTES} バイトを超えます @msg the action set file holds more than 1,000 actions アクションセットファイルのアクションが 1,000 個を超えています @msg the action set file needs a later version of VectorCraft このアクションセットファイルには新しいバージョンの VectorCraft が必要です @msg an action in the action set file has more than 10,000 steps アクションセットファイルに 10,000 ステップを超えるアクションがあります diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 63a17e969..2595036c2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -858,6 +858,7 @@ lighting Light სინათლე Color Picker… ფერის ამრჩევი… Coming soon — tracked in the parity plan მალე — აღრიცხულია თანხვედრის გეგმაში Command Dialog ბრძანების დიალოგი + Command API Reference ბრძანებების API ცნობარი Copy Selected Style მონიშნული სტილის კოპირება Corners… კუთხეები… Define Perspective Grid… პერსპექტივის ბადის განსაზღვრა… @@ -4180,6 +4181,7 @@ font-technology Variable ცვლადი @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n} @msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი @msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: ცნობარი {MAX_BYTES} ბაიტზე დიდი იქნებოდა @msg the action set file holds more than 1,000 actions მოქმედებების ნაკრების ფაილი 1000-ზე მეტ მოქმედებას შეიცავს @msg the action set file needs a later version of VectorCraft მოქმედებების ნაკრების ფაილს VectorCraft-ის უფრო ახალი ვერსია სჭირდება @msg an action in the action set file has more than 10,000 steps მოქმედებების ნაკრების ფაილში ერთ-ერთ მოქმედებას 10 000-ზე მეტი ნაბიჯი აქვს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1463307af..0cc31464f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1243,6 +1243,7 @@ antiAlias Sharp 날카롭게 Coming soon — tracked in the parity plan 곧 제공 예정 — 기능 동등성 계획에서 관리 중 Command Click to Select Objects Behind Command-클릭으로 뒤쪽 개체 선택 Command Dialog 명령 대화상자 + Command API Reference 명령 API 참조 Community 커뮤니티 Compatibility: 호환성: Compatible Gradient and Gradient Mesh Printing 호환 그레이디언트 및 그레이디언트 메시 인쇄 @@ -4728,6 +4729,7 @@ theme Appearance 외관 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다 @msg not an action set file 액션 세트 파일이 아닙니다 @msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다 +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: 참조가 {MAX_BYTES}바이트보다 커집니다 @msg the action set file holds more than 1,000 actions 액션 세트 파일의 액션이 1,000개를 넘습니다 @msg the action set file needs a later version of VectorCraft 이 액션 세트 파일을 열려면 최신 버전의 VectorCraft가 필요합니다 @msg an action in the action set file has more than 10,000 steps 액션 세트 파일에 단계가 10,000개를 넘는 액션이 있습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e512752ea..d38e11ef7 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -920,6 +920,7 @@ lighting Light Licht Color Picker… Kleurkiezer… Coming soon — tracked in the parity plan Binnenkort beschikbaar — opgenomen in het pariteitsplan Command Dialog Opdrachtdialoogvenster + Command API Reference API-naslag voor opdrachten Copy Selected Style Geselecteerde stijl kopiëren Corners… Hoeken… Define Perspective Grid… Perspectiefraster definiëren… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5469364e6..f8c4a23f7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -921,6 +921,7 @@ lighting Light Światło Color Picker… Próbnik kolorów… Coming soon — tracked in the parity plan Już wkrótce — ujęte w planie zgodności Command Dialog Okno polecenia + Command API Reference Dokumentacja API poleceń Copy Selected Style Kopiuj zaznaczony styl Corners… Narożniki… Define Perspective Grid… Zdefiniuj siatkę perspektywy… @@ -4545,6 +4546,7 @@ imageTrace Outline Kontur @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków @msg not an action set file to nie jest plik zestawu operacji @msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: dokumentacja byłaby większa niż {MAX_BYTES} bajtów @msg the action set file holds more than 1,000 actions plik zestawu operacji zawiera ponad 1000 operacji @msg the action set file needs a later version of VectorCraft plik zestawu operacji wymaga nowszej wersji programu VectorCraft @msg an action in the action set file has more than 10,000 steps operacja w pliku zestawu operacji ma ponad 10 000 kroków diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b126286a0..30a61dae3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -532,6 +532,7 @@ antiAlias Sharp Nítido Coming soon — tracked in the parity plan Em breve — rastreado no plano de paridade Command Click to Select Objects Behind Clique com Comando para Selecionar Objetos Atrás Command Dialog Diálogo de Comandos + Command API Reference Referência da API de Comandos Community Comunidade Compatibility: Compatibilidade: Compatible Gradient and Gradient Mesh Printing Impressão Compatível de Gradiente e Malha de Gradiente @@ -3985,6 +3986,7 @@ axis Both Ambos @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas @msg not an action set file não é um arquivo de conjunto de ações @msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referência teria mais de {MAX_BYTES} bytes @msg the action set file holds more than 1,000 actions o arquivo de conjunto de ações contém mais de 1.000 ações @msg the action set file needs a later version of VectorCraft o arquivo de conjunto de ações requer uma versão mais recente do VectorCraft @msg an action in the action set file has more than 10,000 steps uma ação do arquivo de conjunto de ações tem mais de 10.000 etapas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d54e7d199..dd954cea0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -580,6 +580,7 @@ font-technology Variable Вариативные Coming soon — tracked in the parity plan Скоро — отслеживается в плане развития Command Click to Select Objects Behind Cmd+щелчок для выбора объектов позади Command Dialog Диалоговое окно команды + Command API Reference Справочник по API команд Community Сообщество Compatibility: Совместимость: Compatible Gradient and Gradient Mesh Printing Совместимая печать градиентов и градиентных сеток @@ -4449,6 +4450,7 @@ axis Both Оба @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов @msg not an action set file это не файл набора действий @msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: справочник был бы больше {MAX_BYTES} байт @msg the action set file holds more than 1,000 actions файл набора действий содержит более 1000 действий @msg the action set file needs a later version of VectorCraft для файла набора действий нужна более новая версия VectorCraft @msg an action in the action set file has more than 10,000 steps в файле набора действий есть действие более чем из 10 000 шагов diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2fe873c72..d83100b8c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -569,6 +569,7 @@ font-technology Variable Variabla Coming soon — tracked in the parity plan Kommer snart — följs upp i paritetsplanen Command Click to Select Objects Behind Cmd-klicka för att markera objekt bakom Command Dialog Kommandodialog + Command API Reference API-referens för kommandon Community Gemenskap Compatibility: Kompatibilitet: Compatible Gradient and Gradient Mesh Printing Kompatibel utskrift av gradienter och gradientnät @@ -4317,6 +4318,7 @@ axis Both Båda @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades @msg not an action set file inte en fil med åtgärdsuppsättning @msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: referensen skulle bli större än {MAX_BYTES} byte @msg the action set file holds more than 1,000 actions filen med åtgärdsuppsättningen innehåller fler än 1 000 åtgärder @msg the action set file needs a later version of VectorCraft filen med åtgärdsuppsättningen kräver en nyare version av VectorCraft @msg an action in the action set file has more than 10,000 steps en åtgärd i filen med åtgärdsuppsättningen har fler än 10 000 steg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e3f73973c..ba54e4058 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -563,6 +563,7 @@ font-technology Variable Değişken Coming soon — tracked in the parity plan Çok yakında — eşlik planında takip ediliyor Command Click to Select Objects Behind Komut Arkasındaki Nesneleri Seçmek İçin Tıklayın Command Dialog Komut İletişim Kutusu + Command API Reference Komut API Başvurusu Community Topluluk Compatibility: Uyumluluk: Compatible Gradient and Gradient Mesh Printing Uyumlu Degrade ve Degrade Mesh Baskı @@ -4177,6 +4178,7 @@ axis Both Her İkisi @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı @msg not an action set file bir eylem seti dosyası değil @msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: başvuru {MAX_BYTES} bayttan büyük olur @msg the action set file holds more than 1,000 actions eylem seti dosyası 1.000'den fazla eylem içeriyor @msg the action set file needs a later version of VectorCraft eylem seti dosyası VectorCraft'ın daha yeni bir sürümünü gerektiriyor @msg an action in the action set file has more than 10,000 steps eylem seti dosyasındaki bir eylemde 10.000'den fazla adım var diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a91939074..056bf92ec 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -576,6 +576,7 @@ font-technology Variable Варіативні Coming soon — tracked in the parity plan Незабаром відстежується в плані розвитку Command Click to Select Objects Behind Cmd+клацання для вибору об’єктів позаду Command Dialog Діалогове вікно команди + Command API Reference Довідник API команд Community Спільнота Compatibility: Сумісність: Compatible Gradient and Gradient Mesh Printing Сумісний друк градієнтів та градієнтних сіток @@ -4439,6 +4440,7 @@ axis Both Обидві @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків @msg not an action set file це не файл набору дій @msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: довідник був би більшим за {MAX_BYTES} байтів @msg the action set file holds more than 1,000 actions файл набору дій містить понад 1000 дій @msg the action set file needs a later version of VectorCraft для файлу набору дій потрібна новіша версія VectorCraft @msg an action in the action set file has more than 10,000 steps у файлі набору дій є дія з понад 10 000 кроків diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f0780005..624a2dbff 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -671,6 +671,7 @@ font-technology Variable Biến thiên Coming soon — tracked in the parity plan Sắp ra mắt — đang theo dõi trong kế hoạch đối chiếu tính năng Command Click to Select Objects Behind Command-bấm để chọn đối tượng phía sau Command Dialog Hộp thoại lệnh + Command API Reference Tài liệu tham khảo API lệnh Commits Commit Community Cộng đồng Company Công ty @@ -4748,6 +4749,7 @@ theme Appearance Giao diện @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước @msg not an action set file không phải tệp bộ hành động @msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: tài liệu tham khảo sẽ lớn hơn {MAX_BYTES} byte @msg the action set file holds more than 1,000 actions tệp bộ hành động chứa hơn 1.000 hành động @msg the action set file needs a later version of VectorCraft tệp bộ hành động cần phiên bản VectorCraft mới hơn @msg an action in the action set file has more than 10,000 steps một hành động trong tệp bộ hành động có hơn 10.000 bước diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 65a59cabe..501819665 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -528,6 +528,7 @@ antiAlias Sharp 锐利 Coming soon — tracked in the parity plan 即将推出 — 在对标计划中跟踪 Command Click to Select Objects Behind 按住 Command 单击选择后方对象 Command Dialog 命令对话框 + Command API Reference 命令 API 参考 Community 社区 Compatibility: 兼容性: Compatible Gradient and Gradient Mesh Printing 兼容渐变和渐变网格打印 @@ -4508,6 +4509,7 @@ imageTrace Outline 轮廓 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤 @msg not an action set file 不是动作组文件 @msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:参考将大于 {MAX_BYTES} 字节 @msg the action set file holds more than 1,000 actions 动作组文件包含超过 1,000 个动作 @msg the action set file needs a later version of VectorCraft 动作组文件需要更高版本的 VectorCraft @msg an action in the action set file has more than 10,000 steps 动作组文件中有动作超过 10,000 个步骤 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b16ad5eec..eb983db79 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -544,6 +544,7 @@ font-technology Variable 可變 Coming soon — tracked in the parity plan 即將推出 — 已列入功能對等計畫 Command Click to Select Objects Behind Cmd 加按一下可選取後方物件 Command Dialog 指令對話框 + Command API Reference 指令 API 參考 Community 社群 Compatibility: 相容性: Compatible Gradient and Gradient Mesh Printing 相容的漸層與漸層網格列印 @@ -2352,6 +2353,7 @@ file-filter Templates 範本 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟 @msg not an action set file 不是動作組檔案 @msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:參考將大於 {MAX_BYTES} 位元組 @msg the action set file holds more than 1,000 actions 動作組檔案包含超過 1,000 個動作 @msg the action set file needs a later version of VectorCraft 動作組檔案需要較新版本的 VectorCraft @msg an action in the action set file has more than 10,000 steps 動作組檔案中有動作超過 10,000 個步驟 diff --git a/docs/mcp.md b/docs/mcp.md index e99a95c07..2cb91b78e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -210,8 +210,10 @@ echo '{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"poster"," ### Resource templates -`vectorcraft://document` (summary), `vectorcraft://document/json` (the whole model), and -`vectorcraft://commands` (the live command catalog with enabled state) are the fixed resources. The templates read one thing at a time, which matters on a real document: the full model +`vectorcraft://document` (summary), `vectorcraft://document/json` (the whole model), +`vectorcraft://commands` (the live command catalog with enabled state) and `vectorcraft://commands/reference` (the +command API reference in Markdown, `text/markdown; charset=utf-8`; see [Parameter schemas](#parameter-schemas)) are +the fixed resources. The templates read one thing at a time, which matters on a real document: the full model is thousands of lines an agent pays for again on every change. | `uriTemplate` | Reads | @@ -748,6 +750,7 @@ Fixed resources and the four templates (same as [Resource templates](#resource-t | `vectorcraft://document` | `document.inspect` summary (JSON) | | `vectorcraft://document/json` | The complete document model (JSON) | | `vectorcraft://commands` | Live command catalog with enabled state | +| `vectorcraft://commands/reference` | The command API reference (`engine.apiReference`), as Markdown (`text/markdown; charset=utf-8`) | | `vectorcraft://object/{id}` | One layer or object (`document.node {id, summary: true}`) | | `vectorcraft://command/{id}` | One command's metadata and enablement | | `vectorcraft://effect/{id}` | One live effect with parameters and defaults | @@ -1385,7 +1388,7 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd ### Parameter schemas Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`, -`view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options`. The engine's +`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options` and `engine.apiReference`. The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 (https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the command has a summary, and `deprecated: true` when the command is deprecated. The schema uses `type`, `properties`, `required` and @@ -1427,9 +1430,25 @@ path to a value inside a list or an object, and the document and its undo histor An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests -more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 1 -(`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Commands that plug-ins add have -no schema here. +more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 2 +(`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Level 2 added +`engine.apiReference`. Commands that plug-ins add have no schema here. + +`engine.apiReference {}` → `{markdown}` returns the command API reference, written from these schemas each time it +runs; no file of it is kept. It needs no open document and changes nothing. The reference has a `##` section for each +built-in command that has a schema, in registry order (the order `engine.commands` lists them), and leaves out every +command without one, plug-in commands included; `engine.commands` still lists those with their params text. A section +lists the command's label, menu path, API level, deprecation level, what it writes (`x-writes`), cost, summary and +return, then a table of its parameters in the schema's order with the columns Parameter, Type, Range, Unit, Default, +Required, Since, Deprecated and Description. A member of an object parameter is a row of its own after it, named with +dots and `[]` for a list (`edits[].name`). A range says whether each end is included ("more than 0", "at most 64"); a +default is either the JSON value the engine fills in, marked "(JSON)", or the note of a default the command works out; +the Description says when null reads as omitted and when the engine does not refuse a value of another type (a lenient +parameter). Text from a schema is escaped as Markdown text: each of `` \ | [ ] < > ` * _ ! & # ~ : @ `` and the `.` +after `www` get a backslash, and line breaks, tabs and other control characters become spaces, so a description cannot +end a table cell or make a link, an image or HTML. A reference larger than 1 MiB (1,048,576 bytes) is an error naming +`engine.apiReference`, and no part of it is returned. MCP serves the same text as the resource +`vectorcraft://commands/reference`. ## Checking scripts written for the reference app diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1321b98ec..e800e5388 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -672,6 +672,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. - **Input schemas in the command listings:** `engine.commands` and the MCP tools `command_list` and `list_commands` give each command with a parameter schema an `inputSchema` next to `schema`: the same schema with the root `type` `"object"`, the form an MCP tool's input schema takes ([MCP schema reference](https://modelcontextprotocol.io/specification/2026-07-28/schema)). A command without a schema lists neither, and `brief` leaves out both schemas with the `params` text. Tests: `schemas::tests::engine_commands_list_an_input_schema_with_an_object_root` and `schemas::tests::an_input_schema_keeps_deprecation` in `vectorcraft-engine`, and `tests::conventions::command_listings_carry_input_schemas` in `vectorcraft-mcp`, which check every listed input schema against the JSON Schema 2020-12 rules. +- **Command API reference:** `engine.apiReference` returns a Markdown reference written from the parameter schemas when it is asked for, and MCP serves the same text as the resource `vectorcraft://commands/reference` (`text/markdown; charset=utf-8`). Each command that has a schema gets a section in registry order with its label, menu path, API levels, what it writes, summary and return, and a table of its parameters (type, range with its ends marked included or not, unit, default, required, levels and description; members of objects as dotted rows). Commands without a schema, plug-in commands included, are left out. Schema text is escaped so it stays plain text in its table cell, the same build always returns the same bytes, and a reference past 1 MiB is an error rather than a cut-short text. The command's own schema raised the API level to 2. This is a VectorCraft documentation feature; no score changes. Tests: `cmd::apiref::tests` and `schemas::tests` in `vectorcraft-engine`, `resources::tests` in `vectorcraft-mcp`. - **Automation:** - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value. From c703cc56503e816a5990df453403770fc130e75f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 21:37:10 -0800 Subject: [PATCH 10/14] Revolve: a face drawn on its own keeps its fill or stroke's opacity and blending mode A face of a group member that paints nothing (its fill or stroke at 0% opacity) was drawn without a group, so its zero opacity was lost and the member drew fully opaque. Such a face now keeps a group with the opacity and blending mode of its fill or stroke. --- crates/effects/src/revolve.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index d737a0af9..89f3bba86 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -657,7 +657,8 @@ fn group_art(n: &Node, expand: bool) -> Option { } } let runs = runs.into_iter().filter_map(|mut r| { - if r.member.is_none() { + // A face on its own needs a group only for the opacity or blending mode of its fill or stroke. + if r.member.is_none() && r.opacity >= 1.0 && r.blend == Default::default() { return r.faces.pop().map(|face| (r.scopes, face)); } let mut group = Node::group(n.id, r.faces); @@ -949,6 +950,36 @@ mod tests { assert_eq!(groups(&art), 2, "one group for each faded ring"); } + #[test] + fn a_member_whose_fill_or_stroke_paints_nothing_stays_invisible() { + let clear_fill = { + let mut n = (*member(3, 20.0)).clone(); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.opacity = 0.0; + } + n + }; + let clear_stroke = { + let mut n = (*member(4, 20.0)).clone(); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0).into(); + for item in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = item { + s.opacity = 0.0; + } + } + n + }; + for clear in [clear_fill, clear_stroke] { + let art = revolve_art(&group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0), Arc::new(clear)])).unwrap(); + let faces = faces_in_order(&art); + let alone = faces_in_order(&revolve_art(&group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)])).unwrap()); + let opaque: Vec<_> = faces.iter().filter(|(_, s)| s.is_empty()).map(|(f, _)| f).collect(); + assert_eq!(opaque, alone.iter().map(|(f, _)| f).collect::>(), "only the opaque members' faces draw without transparency"); + assert!(faces.len() > alone.len(), "the clear member's faces stay in the art"); + assert!(faces.iter().all(|(_, s)| s.is_empty() || s.iter().any(|(o, _)| *o == 0.0)), "each clear face is drawn at 0% opacity"); + } + } + fn star(id: u64) -> Arc { let anchors = (0..1200) .map(|i| { From 0d7c6dd6e21359389c780e06c5662954a016fcc3 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 10:48:52 +0500 Subject: [PATCH 11/14] Spelling right-click: Intertwine members by their tier at the point (part of #4936) --- crates/ui-egui/src/canvas/tool_overlays.rs | 25 +++++++++----- crates/ui-egui/src/tests/contextmenu.rs | 40 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index 0151b3558..67f6ac861 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -175,18 +175,25 @@ pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option = None; - fn walk<'a>(nodes: &'a [std::sync::Arc], locked: bool, f: &mut impl FnMut(&'a Node, bool)) { + let mut top: Option<(vectorcraft_doc::NodeId, bool)> = None; + // Back to front as drawn at `p`: an Intertwine group's members by their tier there, those of + // one tier in their stacking order ([`vectorcraft_doc::intertwine`]). + fn walk(nodes: &[std::sync::Arc], p: Point, locked: bool, f: &mut impl FnMut(&Node, bool)) { for n in nodes.iter().filter(|n| n.visible) { let locked = locked || n.locked; f(n, locked); - if let Some(children) = n.children() { - walk(children, locked, f); + let Some(children) = n.children() else { continue }; + if matches!(n.kind, NodeKind::Intertwine { .. }) { + let mut drawn = children.to_vec(); + drawn.sort_by_key(|c| vectorcraft_doc::intertwine::tier_at(c, p)); + walk(&drawn, p, locked, f); + } else { + walk(children, p, locked, f); } } } - // Paint order: a later hit is on top. - walk(&st.doc.layers, false, &mut |n, locked| { + // A later hit is on top. + walk(&st.doc.layers, p, false, &mut |n, locked| { let NodeKind::Text(tx) = &n.kind else { return }; if n.geometric_bounds().is_none_or(|b| !b.contains(p)) { return; @@ -194,10 +201,11 @@ pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option Option Date: Mon, 9 Nov 2026 21:53:22 -0800 Subject: [PATCH 12/14] Type: glyph bounds take in underline and strikethrough bars With Measure Type by Glyph Bounds on, point type is measured by its glyph outlines and its underline and strikethrough bars, as the canvas draws them. A typed W or H gives what is drawn that size, and underlined or struck-through spaces are measured by their bars instead of the em box. The texts now say that each point type object selected as an object (not inside a selected group) is measured this way, that moved type snaps by its ink box, and which Arrange Tools commands follow the option: Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects. Arrange in Grid and Group Overlapping Objects keep measuring visual bounds. --- crates/doc/src/text.rs | 3 +- crates/effects/src/preview.rs | 18 +++++++++--- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/typecmd.rs | 4 +-- crates/engine/src/prefs.rs | 5 ++-- crates/engine/src/tests/typeglyphbounds.rs | 32 ++++++++++++++++++++-- crates/tools/src/context.rs | 5 ++-- docs/control-protocol.md | 3 +- docs/mcp.md | 16 +++++++---- docs/target-app-parity.md | 2 +- 10 files changed, 69 insertions(+), 21 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 2d1a60e59..74aa5f444 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -121,7 +121,8 @@ pub struct TextObject { /// vertical column's centre line), filled in with `cached_bounds` (not serialized). #[serde(skip)] pub cached_baselines: Vec<(Point, Point)>, - /// Cached ink of the glyphs in text space (`TextLayout::ink_bounds`, `None` when nothing draws), + /// Cached ink of the glyphs in text space, underline and strikethrough bars included + /// (`vectorcraft_effects::preview::laid_out_ink`, `None` when nothing draws), /// with the `cached_bounds` it was measured with: filled in with `cached_bounds` by the engine's /// text edits, and stale once `cached_bounds` differs ([`Self::ink`]). Not serialized. #[serde(skip)] diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index ee8d4e1c1..ad36ab56d 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -165,8 +165,9 @@ fn measured_in(drawn: &Drawn, n: &Node, a: Affine, m: Measure) -> Option { /// The box of the ink of type `t`'s glyphs with its text space mapped by `a * t.xf`: the cached /// ink ([`vectorcraft_doc::TextObject::ink`]) when that map keeps a rectangle square, else the -/// glyph outlines mapped point by point ([`vectorcraft_text::TextLayout::ink_bounds_in`]). `None` -/// when nothing draws (no text, only spaces, or glyphs without outlines). +/// glyph outlines and the underline and strikethrough bars mapped point by point +/// ([`vectorcraft_text::envelope_ink`]). `None` when nothing draws (no text, only spaces without +/// underline or strikethrough, or glyphs without outlines). pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option { let m = a * t.xf; let [p, q, r, s, _, _] = m.as_coeffs(); @@ -174,11 +175,20 @@ pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option { if square { let ink = match t.ink() { Some(ink) => ink, - None => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds(), + None => vectorcraft_text::envelope_ink(t, Affine::IDENTITY, None), }; return ink.map(|i| m.transform_rect_bbox(i)); } - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds_in(m) + vectorcraft_text::envelope_ink(t, m, None) +} + +/// The ink of type `t` laid out as `layout`, in text space: its glyphs +/// ([`vectorcraft_text::TextLayout::ink_bounds`]) and its underline and strikethrough bars +/// ([`vectorcraft_text::decorations`]), what [`text_ink_in`] measures. `None` when nothing draws. +pub fn laid_out_ink(layout: &vectorcraft_text::TextLayout, t: &vectorcraft_doc::TextObject) -> Option { + vectorcraft_text::decorations(layout, vectorcraft_text::FontDb::global(), t) + .into_iter() + .fold(layout.ink_bounds(), |acc, (_, bar)| union_opt(acc, Some(bar.bounding_box()))) } /// The visual bounds of `n` as drawn: those of its [`drawn_art`], else diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 086bf3843..2f0aa742d 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -287,7 +287,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, point type selected on its own is measured by its glyphs' ink, which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, each point type object selected as an object (not inside a selected group) is measured by its glyphs' ink (underline and strikethrough included), which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 1bf212717..a4fe33762 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -304,7 +304,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); - t.cached_ink = Some((lay.bounds, lay.ink_bounds())); + t.cached_ink = Some((lay.bounds, vectorcraft_render::effects::preview::laid_out_ink(&lay, t))); } /// [`refresh_bounds`] for a document that is opening: the first frame takes the layout @@ -316,7 +316,7 @@ pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); - t.cached_ink = Some((lay.bounds, lay.ink_bounds())); + t.cached_ink = Some((lay.bounds, vectorcraft_render::effects::preview::laid_out_ink(&lay, t))); vectorcraft_text::keep_layout(t, stamp, lay); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index e6fa7cb8d..d6fd21d28 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -43,8 +43,9 @@ pub struct Prefs { pub align_glyph_bounds_area: bool, /// Preferences › Type › Glyph Bounds › Measure Type by Glyph Bounds (also in the Transform /// panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, - /// Y, W and H, and `object.setBounds` measure point type selected on its own by the ink of its - /// glyphs instead of its em box ([`crate::Session::bounds_measure`]). + /// Y, W and H, and `object.setBounds` measure each point type object selected as an object (not + /// inside a selected group) by the ink of its glyphs instead of its em box + /// ([`crate::Session::bounds_measure`]). pub type_glyph_bounds: bool, /// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the /// canvas draws everything outside the artboards darker, the pasteboard and the art on it. diff --git a/crates/engine/src/tests/typeglyphbounds.rs b/crates/engine/src/tests/typeglyphbounds.rs index 0f42fcc7f..9fd8e400b 100644 --- a/crates/engine/src/tests/typeglyphbounds.rs +++ b/crates/engine/src/tests/typeglyphbounds.rs @@ -1,6 +1,6 @@ //! Preferences › Type › Measure Type by Glyph Bounds (`typeGlyphBounds`): the bounding box, the -//! Transform panel's values and `object.setBounds` measure point type selected on its own by the -//! ink of its glyphs instead of its em box. +//! Transform panel's values and `object.setBounds` measure each point type object selected as an +//! object (not inside a selected group) by the ink of its glyphs instead of its em box. use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, OrientedBox, TextObject}; @@ -282,6 +282,34 @@ fn saved_documents_do_not_depend_on_the_preference() { assert_eq!(serde_json::to_string(&s.doc().unwrap().doc).unwrap(), off); } +/// What the type draws on the page: its glyphs and its underline and strikethrough bars. +fn drawn(s: &Session, id: NodeId) -> Rect { + let t = text(s, id); + vectorcraft_text::envelope_ink(&t, t.xf, None).unwrap() +} + +/// Underline and strikethrough bars are drawn with the type, so the box takes them in and a typed +/// height gives what is drawn that height. Underlined spaces are measured by their bar. +#[test] +fn underline_and_strikethrough_bars_count_as_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "H"})); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 1, "underline": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, drawn(&s, t), 1e-9), "{b:?} != {:?}", drawn(&s, t)); + assert!(b.y1 > ink(&s, t).y1 + 1.0, "the underline hangs below the glyph"); + run(&mut s, "object.setBounds", json!({"height": 50})); + assert!((drawn(&s, t).height() - 50.0).abs() < 1e-6, "{:?}", drawn(&s, t)); + assert!(near(s.transform_box(&[t]).unwrap().rect, drawn(&s, t), 1e-6)); + + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 40, "text": " "})); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 3, "strikethrough": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, drawn(&s, t), 1e-9), "{b:?} != {:?}", drawn(&s, t)); + assert!(em(&s, t).height() > b.height() + 10.0, "em box {:?}", em(&s, t)); +} + /// A transform of type keeps its cached ink, which is in text space. #[test] fn the_cached_ink_is_the_laid_out_ink() { diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 971323bf1..741e96229 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -178,8 +178,9 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, - /// Type › Measure Type by Glyph Bounds: the bounding box measures point type selected on its - /// own by the ink of its glyphs ([`vectorcraft_effects::preview::Measure`]). + /// Type › Measure Type by Glyph Bounds: the bounding box measures each point type object + /// selected as an object (not inside a selected group) by the ink of its glyphs + /// ([`vectorcraft_effects::preview::Measure`]). pub glyph_bounds: bool, /// The session's memo of the selection tools' measurements for the document as it is /// ([`select::PreviewMemo`]); without one they measure on every call. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b480106b3..bcf7644d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -531,7 +531,8 @@ Align to Glyph Bounds: the Align panel flyout's Align to Glyph Bounds submenu sh Measure Type by Glyph Bounds: the Transform panel menu's Measure Type by Glyph Bounds item shows and toggles the `typeGlyphBounds` preference (`prefs.set`), checked while on; while it is on, the bounding box and the Transform -panel, Properties and Control bar fields measure point type selected on its own by its glyphs' ink. +panel, Properties and Control bar fields measure each point type object selected as an object (not inside a selected +group) by its glyphs' ink. Align To: the Align panel's Align To choice is saved with the preferences, and `ui.inspect` reports it as `ui.align_to` (`"selection"` or `"artboard"`; Align to Key Object stores no choice). The align buttons of the Align diff --git a/docs/mcp.md b/docs/mcp.md index c33243f42..d22e27342 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3969,17 +3969,23 @@ The bounding box normally measures point type by its em box, which leaves room a ascenders, descenders and capitals. With the preference `typeGlyphBounds` on (`prefs.set {key: "typeGlyphBounds", value: true}`; also Preferences › Type › Glyph Bounds and the Transform panel menu's Measure Type by Glyph Bounds), the bounding box and its handles, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, -`object.setBounds`, the Scale, Rotate and Reflect tools' reference point and the Arrange commands measure point type -selected on its own, horizontal or vertical, by the ink of its glyphs. The option is VectorCraft's own and is off by +`object.setBounds`, the Scale, Rotate and Reflect tools' reference point, and Swap Objects, Replace with Top Object, +Set Size of Each and Sort Objects measure point type, horizontal or vertical, by the ink of its glyphs: its glyph +outlines and its underline and strikethrough bars. This applies to each point type object that is selected as an object +(not inside a selected group), in a selection with other objects too. The option is VectorCraft's own and is off by default. A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that -draws nothing (no text, only spaces), area type, type on a path and type inside a selected group are measured as -before. Align and Distribute follow Align to Glyph Bounds, and snapping and clicks do not change. The preference is -not saved in documents. +draws nothing (no text, or only spaces without underline or strikethrough), area type, type on a path and type inside +a selected group are measured as before. Arrange in Grid and Group Overlapping Objects measure visual bounds as +before, and Align and Distribute follow Align to Glyph Bounds. + +When moved type snaps (Snap to Grid, Snap to Pixel, Smart Guides and ruler guides), the edges and center of its ink box +snap, and the Distance Guides measure from that box. The other objects that it snaps to keep their usual boxes, as +they do with Use Preview Bounds. The preference is not saved in documents. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeGlyphBounds":true}}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2140f5410..c769dafd2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1052,7 +1052,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds` and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). + - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. The Lights list, Add Light and Delete Light manage one to ten independent colored lights; ambient is shared and visibility remains approximate ([revolve.md](revolve.md)). From bd0515ccc8b75f82b535e7df7e745366b5097569 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 22:16:58 -0800 Subject: [PATCH 13/14] SVG export: mask and filter regions cover the art they apply to Opacity masks, inverted and unclipped masks, and knockout groups were written with mask and filter regions of 200,000 x 200,000 units around the origin. CairoSVG draws each mask into an image of its region's size and stops with "out of memory", so a file with any of them did not open; Firefox drew inverted masks wrong and WebKit drew knockout groups and inverted masks wrong. Each region now covers what the masked or knocked-out art paints: its visual box grown by its raster effects, brush art and members' reach, the mask art's box, and in a scaled symbol instance its strokes and effects at their own size. A mask whose art reaches past its object still comes back whole on import. --- crates/svg/src/export.rs | 13 ++- crates/svg/src/export/nodes.rs | 137 +++++++++++++++++++++++--- crates/svg/tests/it/blend_export.rs | 63 ++++++++++++ crates/svg/tests/it/mask_options.rs | 21 ++++ crates/svg/tests/it/symbols_layers.rs | 25 +++++ docs/file-format-parity.md | 2 +- 6 files changed, 243 insertions(+), 18 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 13c426847..d671522f5 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -98,7 +98,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: brushes: None, knockout: doc.page_knockout, anonymous: false, - knockout_filter: None, + knockout_filters: HashMap::new(), + region_boxes: std::cell::OnceCell::new(), + art_reach: std::cell::RefCell::default(), warnings: vec![], id_prefix, linked: Vec::new(), @@ -255,8 +257,13 @@ struct Writer<'a> { knockout: bool, /// Writing a copy of an object (a knockout mask): no object ids, so they stay unique. anonymous: bool, - /// The filter that paints art black keeping its alpha (knockout masks), once defined. - knockout_filter: Option, + /// The filters that paint art black keeping its alpha (knockout masks), by the region + /// attributes they were defined with. + knockout_filters: HashMap, + /// The document's symbol boxes, measured once for every mask and filter region. + region_boxes: std::cell::OnceCell>, + /// How far each symbol's art paints past its box, by name, once measured for a region. + art_reach: std::cell::RefCell>, /// The brush library, parsed when the first brushed stroke is written. brushes: Option>, /// Features written approximately (once each). diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index adcd3fd25..b31fa1099 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_valid}; use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; @@ -143,9 +145,12 @@ fn pin_to_page(n: &mut Node, m: Affine) { const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups"; pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; -/// A region covering any artwork (mask and filter extents). +/// The region of a mask or filter over art that has no box to measure: one covering any artwork. const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; +/// How deep [`Writer::paint_reach`] looks into members and symbol art. +const MAX_REACH_DEPTH: u32 = 64; + /// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the /// canvas and PDF take it) rather than from linearised ones. pub(super) const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; @@ -354,13 +359,18 @@ impl Writer<'_> { return self.node_body(&bare); } let mid = self.fresh_id("mask"); + let mut bare = n.clone(); + bare.extras_mut().mask = None; + // The region covers what the object and the mask art paint: readers draw the mask into an + // image of the region's size. + let region = self.region_attrs(&[&bare, &m.art]); // Mask art is a picture of its own: it takes no part in a knockout group around the object. let knockout = std::mem::take(&mut self.knockout); let art = self.detached(2, |w| w.node(&m.art)); self.knockout = knockout; let inv = m.invert.then(|| { let fid = self.fresh_id("invert"); - self.def(1, &format!("")); + self.def(1, &format!("")); self.def(2, ""); self.def(1, ""); fid @@ -368,13 +378,13 @@ impl Writer<'_> { // The mask's options, so import restores them (and its art without backdrop and filter). let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; - self.def(1, &format!("")); + self.def(1, &format!("")); if let Some(fid) = &inv { self.def(1, &format!("")); } if !m.clip || m.invert { let c = if m.clip { "black" } else { "white" }; - self.def(1, &format!("")); + self.def(1, &format!("")); } self.defs.push_str(&art); if inv.is_some() { @@ -383,8 +393,6 @@ impl Writer<'_> { self.def(1, ""); self.line(&format!("")); self.depth += 1; - let mut bare = n.clone(); - bare.extras_mut().mask = None; self.node_body(&bare); self.depth -= 1; self.line(""); @@ -399,6 +407,102 @@ impl Writer<'_> { std::mem::replace(&mut self.body, body) } + /// The `x`, `y`, `width` and `height` of a mask or filter region over `nodes`, in the current + /// user space: the box of everything they paint ([`Self::paint_reach`]), 2 larger on each + /// side for antialiasing. [`BIG`] when none of them has a box, or when one nests deeper than + /// [`MAX_REACH_DEPTH`]. The art of a symbol instance written in place keeps its strokes and + /// effects at their own size while the instance scales its geometry, so the region also covers + /// the geometry's box, placed, grown by the strokes' and effects' reach unscaled. + fn region_attrs(&self, nodes: &[&Node]) -> String { + let symbols = self.region_boxes.get_or_init(|| self.doc.symbol_boxes()); + let mut art_reach = self.art_reach.borrow_mut(); + let mut painted: Option = None; + for n in nodes { + let Some(v) = n.visual_bounds_with(symbols) else { continue }; + let Some(r) = self.paint_reach(symbols, &mut art_reach, n, v, 0) else { + return BIG.to_string(); + }; + let g = n.geometric_bounds_with(symbols).unwrap_or(v); + let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max); + let scaled = self.xf.transform_rect_bbox(v.inflate(r, r)); + let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r); + let both = scaled.union(unscaled); + painted = Some(painted.map_or(both, |p| p.union(both))); + } + let Some(b) = painted.map(|b| b.inflate(2.0, 2.0)).filter(|b| b.is_finite()) else { + return BIG.to_string(); + }; + format!("x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())) + } + + /// How far what `n` paints reaches past its visual box `b`: the reach of its own raster effects + /// and of those of its fills and strokes (measured as [`Self::open_filters`] measures them), + /// how far brush art can paint past its brushed strokes (`16 × w + 8` for the widest brushed + /// stroke `w`, as the canvas allows for it), and the most that one of its members or its + /// symbol's art reaches past its own box, spread by its own effects. `art_reach` keeps each + /// symbol's art reach by name once measured, so art used many times is measured once. `None` + /// when members or symbol art nest deeper than [`MAX_REACH_DEPTH`]. + fn paint_reach( + &self, + symbols: &vectorcraft_doc::SymbolBoxes, + art_reach: &mut HashMap, + n: &Node, + b: Rect, + depth: u32, + ) -> Option { + use vectorcraft_effects::RasterFx; + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = 18.0 / self.doc.raster_ppi(); + let reach = |effects: &[vectorcraft_doc::Effect]| -> f64 { + vectorcraft_effects::raster_effects(effects) + .iter() + .map(|f| f.outset(b) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }) + .sum() + }; + let items = n.appearance.items.iter().map(|i| reach(i.effects())).fold(0.0, f64::max); + let brush = if vectorcraft_brush::has_brush(n) { + let w = + n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max); + 16.0 * w + 8.0 + } else { + 0.0 + }; + let own = reach(&n.appearance.effects) + items + brush; + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => self.doc.symbols.iter().find(|s| s.name == *symbol).map(|s| (s, xf)), + _ => None, + }; + if n.children().is_none_or(|c| c.is_empty()) && symbol.is_none() { + return Some(own); + } + if depth >= MAX_REACH_DEPTH { + return None; + } + let mut inner: f64 = 0.0; + for c in n.children().into_iter().flatten() { + if let Some(cb) = c.visual_bounds_with(symbols) { + inner = inner.max(self.paint_reach(symbols, art_reach, c, cb, depth + 1)?); + } + } + if let Some((s, xf)) = symbol { + let art = match art_reach.get(&s.name) { + Some(r) => *r, + None => { + let r = match s.art.visual_bounds_with(symbols) { + Some(ab) => self.paint_reach(symbols, art_reach, &s.art, ab, depth + 1)?, + None => 0.0, + }; + art_reach.insert(s.name.clone(), r); + r + } + }; + // The art's reach, measured in symbol space, as far as the instance can stretch it. + let [xx, xy, yx, yy, _, _] = xf.as_coeffs(); + inner = inner.max(art * xx.hypot(xy).max(yx.hypot(yy)).max(1.0)); + } + Some(own + inner) + } + /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. fn group_props(&self, n: &Node) -> Props { let mut p = css::transparency(n); @@ -450,9 +554,12 @@ impl Writer<'_> { written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c)))); } let after = self.symbol_nodes; + // Each mask covers what the group's elements paint. + let all: Vec<&Node> = elements.iter().map(|c| &***c).collect(); + let region = self.region_attrs(&all); for (c, (before, _)) in rest.iter().zip(&written).rev() { self.symbol_nodes = *before; - let mid = self.knockout_mask(c); + let mid = self.knockout_mask(c, ®ion); self.line(&format!("")); self.depth += 1; } @@ -466,16 +573,18 @@ impl Writer<'_> { } /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object - /// opacity without its own mask, unless those define its shape). Returns the mask id. - fn knockout_mask(&mut self, c: &Node) -> String { - let filter = match &self.knockout_filter { + /// opacity without its own mask, unless those define its shape). `region` is the attributes of + /// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares. + /// Returns the mask id. + fn knockout_mask(&mut self, c: &Node, region: &str) -> String { + let filter = match self.knockout_filters.get(region) { Some(f) => f.clone(), None => { let f = self.fresh_id("knockout-shape"); - self.def(1, &format!("")); + self.def(1, &format!("")); self.def(2, ""); self.def(1, ""); - self.knockout_filter = Some(f.clone()); + self.knockout_filters.insert(region.to_string(), f.clone()); f } }; @@ -490,8 +599,8 @@ impl Writer<'_> { let art = self.detached(3, |w| w.node(&shape)); self.anonymous = anonymous; let mid = self.fresh_id("knockout"); - self.def(1, &format!("")); - self.def(2, &format!("")); + self.def(1, &format!("")); + self.def(2, &format!("")); self.def(2, &format!("")); self.defs.push_str(&art); self.def(2, ""); diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index 65fb26799..0109897ca 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -138,3 +138,66 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() { let svg = export(&d, &ExportOptions::default()); assert!(svg.contains("` and `` element in `svg`. +fn regions(svg: &str) -> Vec<[f64; 4]> { + let opts = resvg::usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let xml = resvg::usvg::roxmltree::Document::parse_with_options(svg, opts).unwrap(); + xml.descendants() + .filter(|n| matches!(n.tag_name().name(), "mask" | "filter")) + .map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN))) + .collect() +} + +/// Mask and filter regions cover the art they apply to and no more: readers draw a mask into an +/// image of its region's size, and one drawn at 200,000 units square runs out of memory. +#[test] +fn mask_and_filter_regions_cover_their_art() { + let knockout = { + let mut d = Document::new(40.0, 20.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [200, 0, 0]); + let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), [0, 0, 200]); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d + }; + for (what, d) in [("clip", masked(true, false)), ("no clip, inverted", masked(false, true)), ("knockout", knockout)] { + let svg = export(&d, &ExportOptions::default()); + let found = regions(&svg); + assert!(!found.is_empty(), "{what}: {svg}"); + for [x, y, w, h] in found { + // The art is 40 × 20, with 2 to spare on each side. + assert!(x >= -2.5 && y >= -2.5 && w <= 45.0 && h <= 25.0 && w >= 40.0 && h >= 20.0, "{what}: {x} {y} {w} {h} in {svg}"); + } + } +} + +/// A mask's region reaches as far as the masked art paints: a drop shadow of the object, or of an +/// object in the masked group, shows past the object as on the canvas. +#[test] +fn a_mask_region_keeps_the_shadows_of_the_masked_art() { + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + }; + for in_group in [false, true] { + let mut d = Document::new(60.0, 60.0); + let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]); + red.appearance.effects.push(shadow.clone()); + let mut masked = if in_group { Node::group(d.alloc_id(), vec![red.into()]) } else { red }; + // An unclipped mask lets the art show outside the mask art: all of the shadow shows. + let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, masked); + let svg = export(&d, &ExportOptions::default()); + let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 60, 60)); + for (x, y) in [(15, 15), (40, 40), (35, 28)] { + let (c, w) = (canvas.over_white(x, y), written.over_white(x, y)); + assert!(within(c, w, 3), "in a group {in_group}, at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}"); + } + assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40)); + } +} diff --git a/crates/svg/tests/it/mask_options.rs b/crates/svg/tests/it/mask_options.rs index 541a8f702..16c021391 100644 --- a/crates/svg/tests/it/mask_options.rs +++ b/crates/svg/tests/it/mask_options.rs @@ -55,6 +55,27 @@ fn an_inverted_unclipped_mask_round_trips() { } } +/// Mask art that reaches past the masked object comes back whole: the mask's region covers the art +/// as well as the object, so import does not clip the art to the region. +#[test] +fn mask_art_larger_than_its_object_round_trips_unclipped() { + let mut d = Document::new(200.0, 200.0); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), red); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 20.0, 180.0, 170.0)), white); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let s = export(&d, &ExportOptions::default()); + let (back, warnings) = import_with_report(&s).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") }; + assert!(m.art.path_data().is_some(), "the art alone, not clipped by the region: {:?}\n{s}", m.art.kind); + let b = m.art.geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 170.0).abs() < 1e-6, "{b:?}"); +} + #[test] fn foreign_masks_keep_their_art() { // Without the attribute a white backdrop is part of the mask art (another app's file). diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs index 0fad6ff09..3e58b0228 100644 --- a/crates/svg/tests/it/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -296,6 +296,31 @@ fn masks_in_symbols_move_with_the_art_when_linked() { } } +/// A masked object in a symbol's art, drawn by an instance that scales it down: its stroke keeps +/// its weight, and the mask's region still covers all of it. +#[test] +fn a_mask_in_a_scaled_instance_covers_the_strokes_at_their_weight() { + let mut d = Document::new(120.0, 120.0); + let stroked = Appearance::basic(Paint::solid(Color::rgb8(200, 0, 0)), Paint::solid(Color::BLACK), 20.0); + let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), stroked); + let shape = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0), + ); + art.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(shape, false))); + d.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(art) }); + instance(&mut d, "Framed", Affine::translate((50.0, 50.0)) * Affine::scale(0.2)); + let svg = export(&d, &ExportOptions::default()); + let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 120, 120)); + // The stroke runs 10 either side of the 20 × 20 square's edge at (50, 50). + for (x, y) in [(43, 60), (77, 60), (60, 43), (60, 77)] { + let (c, w) = (canvas.over_white(x, y), written.over_white(x, y)); + assert!(c.iter().all(|v| *v < 60), "the stroke shows on the canvas at {x},{y}: {c:?}"); + assert!(c.iter().zip(w).all(|(a, b)| (*a as i32 - b as i32).abs() <= 8), "at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}"); + } +} + #[test] fn symbols_inside_themselves_end() { let mut d = symbol_doc(1.0, &[Affine::translate((50.0, 50.0))]); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 10dded623..ad63dbe5f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -286,7 +286,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to (`crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | From 8ae9ca2012fc3b6113e6ad21c5adc919329555ff Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 22:37:17 -0800 Subject: [PATCH 14/14] SVG export: masks over type keep the full region; live art measured expanded A mask, inverted mask or knockout group over type now keeps the region that covers any artwork. Type is measured by its layout or, on a path, by the path, and a reader that sets live type in its own font can draw it wider, so a region sized from that box could cut glyphs off. A live object (blend, envelope, repeat, mesh, placed document, intertwine) under a mask is measured as the art the writer writes for it, expanded on its own with the same limits. The writer keeps its symbol boxes and symbols' art reach in plain fields instead of cells. --- crates/svg/src/export.rs | 8 ++-- crates/svg/src/export/nodes.rs | 51 +++++++++++++++------ crates/svg/tests/it/blend_export.rs | 69 +++++++++++++++++++++++++++-- docs/file-format-parity.md | 2 +- 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index d671522f5..eed8ecd33 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -99,8 +99,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: knockout: doc.page_knockout, anonymous: false, knockout_filters: HashMap::new(), - region_boxes: std::cell::OnceCell::new(), - art_reach: std::cell::RefCell::default(), + region_boxes: None, + art_reach: HashMap::new(), warnings: vec![], id_prefix, linked: Vec::new(), @@ -261,9 +261,9 @@ struct Writer<'a> { /// attributes they were defined with. knockout_filters: HashMap<String, String>, /// The document's symbol boxes, measured once for every mask and filter region. - region_boxes: std::cell::OnceCell<vectorcraft_doc::SymbolBoxes<'a>>, + region_boxes: Option<vectorcraft_doc::SymbolBoxes<'a>>, /// How far each symbol's art paints past its box, by name, once measured for a region. - art_reach: std::cell::RefCell<HashMap<String, f64>>, + art_reach: HashMap<String, f64>, /// The brush library, parsed when the first brushed stroke is written. brushes: Option<Vec<vectorcraft_brush::Brush>>, /// Features written approximately (once each). diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index b31fa1099..129bd83e2 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -1,11 +1,9 @@ -use std::collections::HashMap; - use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_valid}; use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; use super::{ - Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, Writer, - Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, + Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, HashMap, Node, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, StrokeLayer, + Writer, Written, WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, }; /// A symbol's `<symbol>` def and the instance transforms that can `<use>` it. @@ -409,17 +407,27 @@ impl Writer<'_> { /// The `x`, `y`, `width` and `height` of a mask or filter region over `nodes`, in the current /// user space: the box of everything they paint ([`Self::paint_reach`]), 2 larger on each - /// side for antialiasing. [`BIG`] when none of them has a box, or when one nests deeper than - /// [`MAX_REACH_DEPTH`]. The art of a symbol instance written in place keeps its strokes and - /// effects at their own size while the instance scales its geometry, so the region also covers - /// the geometry's box, placed, grown by the strokes' and effects' reach unscaled. - fn region_attrs(&self, nodes: &[&Node]) -> String { - let symbols = self.region_boxes.get_or_init(|| self.doc.symbol_boxes()); - let mut art_reach = self.art_reach.borrow_mut(); + /// side for antialiasing. [`BIG`] when none of them has a box, when one holds type (a reader + /// sets live type in its own font, which can be wider, and type on a path is measured by its + /// path), or when one nests deeper than [`MAX_REACH_DEPTH`]. The art of a symbol instance + /// written in place keeps its strokes and effects at their own size while the instance scales + /// its geometry, so the region also covers the geometry's box, placed, grown by the strokes' + /// and effects' reach unscaled. + fn region_attrs(&mut self, nodes: &[&Node]) -> String { + let symbols = self.region_boxes.take().unwrap_or_else(|| self.doc.symbol_boxes()); + let mut art_reach = std::mem::take(&mut self.art_reach); + let region = self.region_of(&symbols, &mut art_reach, nodes); + self.region_boxes = Some(symbols); + self.art_reach = art_reach; + region + } + + /// [`Self::region_attrs`] with the document's symbol boxes and the symbols' art reach so far. + fn region_of(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap<String, f64>, nodes: &[&Node]) -> String { let mut painted: Option<Rect> = None; for n in nodes { let Some(v) = n.visual_bounds_with(symbols) else { continue }; - let Some(r) = self.paint_reach(symbols, &mut art_reach, n, v, 0) else { + let Some(r) = self.paint_reach(symbols, art_reach, n, v, 0) else { return BIG.to_string(); }; let g = n.geometric_bounds_with(symbols).unwrap_or(v); @@ -440,8 +448,10 @@ impl Writer<'_> { /// how far brush art can paint past its brushed strokes (`16 × w + 8` for the widest brushed /// stroke `w`, as the canvas allows for it), and the most that one of its members or its /// symbol's art reaches past its own box, spread by its own effects. `art_reach` keeps each - /// symbol's art reach by name once measured, so art used many times is measured once. `None` - /// when members or symbol art nest deeper than [`MAX_REACH_DEPTH`]. + /// symbol's art reach by name once measured, so art used many times is measured once. A live + /// object is measured as the art it is written as: expanded on its own, as [`Self::node`] + /// expands it, so with the same limits. `None` when `n`, its members or its symbol's art hold + /// type, or nest deeper than [`MAX_REACH_DEPTH`]. fn paint_reach( &self, symbols: &vectorcraft_doc::SymbolBoxes, @@ -451,6 +461,10 @@ impl Writer<'_> { depth: u32, ) -> Option<f64> { use vectorcraft_effects::RasterFx; + // Type draws past its measured box in a reader's own font. + if matches!(n.kind, NodeKind::Text(_)) { + return None; + } // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. let grow = 18.0 / self.doc.raster_ppi(); let reach = |effects: &[vectorcraft_doc::Effect]| -> f64 { @@ -468,6 +482,15 @@ impl Writer<'_> { 0.0 }; let own = reach(&n.appearance.effects) + items + brush; + if vectorcraft_doc::live::is_live(n) { + if depth >= MAX_REACH_DEPTH { + return None; + } + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + let Some(gb) = g.visual_bounds_with(symbols) else { return Some(own) }; + let past = [b.x0 - gb.x0, b.y0 - gb.y0, gb.x1 - b.x1, gb.y1 - b.y1].into_iter().fold(0.0, f64::max); + return Some(own + past + self.paint_reach(symbols, art_reach, &g, gb, depth + 1)?); + } let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, xf } => self.doc.symbols.iter().find(|s| s.name == *symbol).map(|s| (s, xf)), _ => None, diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index 0109897ca..3804d30ed 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -1,10 +1,11 @@ //! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the //! reference formulas when resvg draws the file, and masks take luminance the canvas's way. +use usvg::roxmltree; use vectorcraft_color::blend::blend_rgb; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, Document, Node, OpacityMask}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, OpacityMask, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; use vectorcraft_svg::{ExportOptions, export}; use vectorcraft_testkit::raster::{Image, render_artboard}; @@ -141,8 +142,7 @@ fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() { /// The `x`, `y`, `width` and `height` of each `<mask>` and `<filter>` element in `svg`. fn regions(svg: &str) -> Vec<[f64; 4]> { - let opts = resvg::usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; - let xml = resvg::usvg::roxmltree::Document::parse_with_options(svg, opts).unwrap(); + let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); xml.descendants() .filter(|n| matches!(n.tag_name().name(), "mask" | "filter")) .map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN))) @@ -201,3 +201,64 @@ fn a_mask_region_keeps_the_shadows_of_the_masked_art() { assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40)); } } + +/// Type is measured by its layout or its path, and a reader sets live type in its own font, so a +/// mask or knockout over type keeps the region that covers any artwork. +#[test] +fn mask_and_filter_regions_over_type_cover_any_artwork() { + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let text = |d: &mut Document| Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style.clone())))); + let masked = { + let mut d = Document::new(80.0, 40.0); + let mut t = text(&mut d); + let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]); + t.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, t); + d + }; + let knockout = { + let mut d = Document::new(80.0, 40.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 80.0, 40.0), [200, 0, 0]); + let mut top = text(&mut d); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d + }; + for (what, d) in [("masked type", masked), ("type in a knockout group", knockout)] { + let svg = export(&d, &ExportOptions::default()); + let found = regions(&svg); + assert!(!found.is_empty(), "{what}: {svg}"); + for [x, y, w, h] in found { + assert!(x <= -100000.0 && y <= -100000.0 && w >= 200000.0 && h >= 200000.0, "{what}: {x} {y} {w} {h} in {svg}"); + } + } +} + +/// A live object under a mask is measured as it is written, expanded: the mask's region covers +/// the drop shadows of a blend's steps. +#[test] +fn a_mask_region_covers_a_blend_as_written() { + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + }; + let mut d = Document::new(100.0, 50.0); + let mut a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), [255, 0, 0]); + let mut b = rect(&mut d, Rect::new(70.0, 10.0, 80.0, 20.0), [0, 0, 255]); + a.appearance.effects.push(shadow.clone()); + b.appearance.effects.push(shadow); + let mut blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![a.into(), b.into()], spec: Default::default() }); + let art = rect(&mut d, Rect::new(0.0, 0.0, 5.0, 5.0), [255, 255, 255]); + blend.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, blend); + let svg = export(&d, &ExportOptions::default()); + // The steps' drop shadows are filters with regions of their own; the mask's covers them all: + // the steps run from 10 to 80 across, and their shadows 15 further. + let xml = roxmltree::Document::parse_with_options(&svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); + let mask = xml.descendants().find(|n| n.has_tag_name("mask")).unwrap(); + let [x, y, w, h] = ["x", "y", "width", "height"].map(|a| mask.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(f64::NAN)); + assert!(x <= 0.0 && y <= 0.0 && x + w >= 95.0 && y + h >= 35.0 && w < 200.0 && h < 200.0, "{x} {y} {w} {h} in {svg}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ad63dbe5f..b1c305db1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -286,7 +286,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to (`crates/svg/tests/it/blend_export.rs`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) |