From efd7f3e97a27d22c82d6d89144b99bc619012599 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 03:38:35 -0700 Subject: [PATCH 01/38] Blend: a pending pick starts over when the document's history changes The Blend tool kept its first pick, its clicked anchor and the blend it was adding to through edits made outside the tool. Picking A, recoloring other art and clicking B made a blend of A and B; undoing A's creation left the next click doing nothing. History::step names the current step by the serial numbers of the newest undo step and the next redo step. The session keeps the active document's uid and step from the tool's last pointer event and calls the new Tool::history_moved at the next pointer event, or before drawing overlays, when they differ. The tool's own edits are recorded after its actions; a selection tool lent by Cmd is not told. BlendTool clears its pick and chain there, as Escape does. A selection change keeps them. --- crates/engine/src/history.rs | 28 +++++++++- crates/engine/src/lib.rs | 4 ++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/blendopts.rs | 74 +++++++++++++++++++++++++- crates/engine/src/tooling.rs | 28 ++++++++++ crates/tools/src/lib.rs | 4 ++ crates/tools/src/meshblend.rs | 5 ++ 7 files changed, 141 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index ec53acd5b..ee6adccc4 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -19,6 +19,14 @@ pub struct HistoryEntry { next: usize, /// The template and name `label` was made from ([`Session::edit_named`]). pub(crate) named: Option, + /// A number no other step takes ([`History::step`]). + serial: u64, +} + +/// The next [`HistoryEntry::serial`]: 1 for the first step made, so 0 never names a step. +fn next_serial() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) } /// A step name made by filling the `{name}` placeholder of a template with a name @@ -45,7 +53,7 @@ impl HistoryEntry { pub(crate) fn kept(label: String, doc: Arc, selection: Arc, next: &Document) -> Self { doc.drop_lookup_cache(); let (bytes, next_bytes) = doc.retained_bytes_each(next); - HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize, named: None } + HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize, named: None, serial: next_serial() } } /// The template and name the step's label was made from, while the label is still the one @@ -66,7 +74,16 @@ impl HistoryEntry { return Self::kept(label, doc, selection, now); } doc.drop_lookup_cache(); - HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize, named: None } + HistoryEntry { + label, + doc, + selection, + bytes: costs.next_bytes, + next_bytes: costs.bytes, + next: Arc::as_ptr(now) as usize, + named: None, + serial: next_serial(), + } } /// The figures [`HistoryEntry::turned`] reuses. @@ -111,6 +128,13 @@ impl History { self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum() } + /// Which step the document is at: the newest undo step and the next redo step, by numbers no + /// other step takes (0 for none). Every new step, Undo and Redo changes it; an edit merged into + /// the newest step (an undo group) and a selection change do not. + pub fn step(&self) -> (u64, u64) { + (self.undo.last().map_or(0, |e| e.serial), self.redo.last().map_or(0, |e| e.serial)) + } + /// Let the oldest steps go while over the step limit or the memory budget: undo steps first /// (the oldest), then redo steps (the furthest ahead). pub fn trim(&mut self) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7f32ca994..226c391d3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -60,6 +60,10 @@ pub struct Session { /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). pub(crate) lender: Option>, + /// The active document (its uid) and its [`History::step`] when the tool last handled a pointer + /// event or drew its overlays: when they differ at the next one, the tool is told first + /// ([`vectorcraft_tools::Tool::history_moved`]). None until the first. + pub(crate) tool_step: Option<(u64, (u64, u64))>, /// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd /// lends the other tools. None until one is chosen. pub(crate) last_selection_tool: Option<&'static str>, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 89d846330..c073369aa 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -31,6 +31,7 @@ impl Session { journal: Journal::default(), tool: vectorcraft_tools::create("selection"), lender: None, + tool_step: None, last_selection_tool: None, last_view: ViewInfo::default(), preview_memo: Default::default(), diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 97f356078..ec05893f8 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -1,6 +1,6 @@ //! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool: //! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and -//! Escape dropping the first pick. +//! Escape, or an edit made outside the tool, dropping the first pick. use serde_json::{Value, json}; use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; @@ -167,6 +167,78 @@ fn escape_drops_the_blend_tools_first_pick() { assert_eq!(spec(&s, g).0, vec![b, c], "a was not added"); } +#[test] +fn an_edit_or_undo_made_outside_the_blend_tool_drops_its_first_pick() { + for undo in [false, true] { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + // Undo takes away c, the object picked first; an edit recolors c, which is not picked. + click(&mut s, if undo { 420.0 } else { 20.0 }, 20.0); + assert!(!s.overlays(ViewInfo::default()).is_empty()); + if undo { + s.execute("edit.undo", &json!({})).unwrap(); + } else { + s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(); + } + assert!(s.overlays(ViewInfo::default()).is_empty(), "undo {undo}: the pick is not marked"); + let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len()); + click(&mut s, 220.0, 20.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![b], "undo {undo}: b is picked first"); + assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "undo {undo}: the click changes no art"); + click(&mut s, if undo { 20.0 } else { 420.0 }, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "undo {undo}"); + } +} + +#[test] +fn removing_the_blend_the_blend_tool_adds_to_ends_adding() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + let (d, e) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0)); + let other = id_of(&s.execute("object.blend.make", &json!({"ids": [d.0, e.0]})).unwrap()); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + click(&mut s, 20.0, 20.0); + click(&mut s, 220.0, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + s.execute("edit.clear", &json!({"ids": [g.0]})).unwrap(); + sel(&mut s, &[other]); + click(&mut s, 420.0, 20.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![c], "c is picked first"); + assert_eq!(spec(&s, other).0, vec![d, e], "c is not added to the selected blend"); + assert!([a, b, g].iter().all(|id| s.doc().unwrap().doc.node(*id).is_none()), "the blend went with its keys"); +} + +#[test] +fn a_selection_change_keeps_the_blend_tools_first_pick() { + for drag in [false, true] { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + let view = ViewInfo::default(); + s.select_tool("blend", view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 20.0, 20.0), view).unwrap(); + sel(&mut s, &[c]); + // Cmd lends the selection tool: a click on c selects it, a drag moves it (an edit). + let cmd = Mods { cmd: true, ..Mods::default() }; + let to = if drag { 440.0 } else { 420.0 }; + s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 20.0).with_mods(cmd), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to, 20.0).with_mods(cmd), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to, 20.0).with_mods(cmd), view).unwrap(); + assert_eq!(s.tool_id(), "blend"); + assert_eq!(s.overlays(view).is_empty(), drag, "drag {drag}"); + s.pointer(&PointerEvent::new(PointerKind::Down, 220.0, 20.0), view).unwrap(); + if drag { + assert_eq!(s.doc().unwrap().selection.objects, vec![b], "the drag dropped the pick"); + } else { + let g = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g).0, vec![a, b]); + } + } +} + #[test] fn start_points_round_trip_and_old_blends_load() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 81f8616a8..94d2d6742 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -328,6 +328,25 @@ impl Session { self.tool_panic.take().map_or(Ok(()), Err) } + /// The active document (its uid) and its [`crate::History::step`]. + fn history_step(&self) -> Option<(u64, (u64, u64))> { + self.active().map(|d| (d.uid, d.history.step())) + } + + /// Tell the active tool when the history moved, or another document became active, since it + /// last handled a pointer event or was last told ([`vectorcraft_tools::Tool::history_moved`]). + /// A lent selection tool is not told. + fn tell_tool_history_moved(&mut self) { + if self.lender.is_some() { + return; + } + let now = self.history_step(); + if self.tool_step.is_some_and(|seen| Some(seen) != now) { + self.tool.history_moved(); + } + self.tool_step = now; + } + /// Feed a pointer event to the active tool. Returns requests for the UI. pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { self.last_view = view; @@ -341,7 +360,9 @@ impl Session { return r; } self.leave_draw_inside(ev, view)?; + self.tell_tool_history_moved(); let ev = &self.lend_selection_tool(ev); + let lent = self.lender.is_some(); let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); let r = self.take_tool_panic().and_then(|()| { // A gesture may change options (Alt-drag sizes a Liquify brush): keep them. @@ -350,6 +371,11 @@ impl Session { } self.apply_actions(acts) }); + // The tool's own edits do not move the history for it. A selection tool that Cmd lends + // edits for the lender, which hears of the edit at its next event. + if !lent { + self.tool_step = self.history_step(); + } if ev.kind == PointerKind::Up { self.give_back_tool(view)?; } @@ -553,6 +579,8 @@ impl Session { } pub fn overlays(&mut self, view: ViewInfo) -> Vec { + // The Blend tool's first pick is not marked after an edit that dropped it. + self.tell_tool_history_moved(); let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); // The Type tools draw the ports of selected type themselves. if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index e55bb4069..686e00408 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -224,6 +224,10 @@ pub trait Tool: Send { fn after_command(&mut self, _cx: &ToolContext) -> Vec { vec![] } + /// The undo history moved outside the tool (an edit, Undo or Redo the tool did not make), or + /// another document became active. The Blend tool drops its first pick and stops adding to the + /// blend it just made. + fn history_moved(&mut self) {} /// Time passes while the pointer button is held (`dt` seconds since the last tick, whether or /// not the pointer moved): tools that keep working while the brush holds still (Twirl, Pucker, /// Bloat) act on it. The host supplies the time, so tests and agents drive it exactly. diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index d6a46faf0..e881d54c4 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -148,6 +148,11 @@ impl Tool for BlendTool { (self.first, self.chained) = (None, false); vec![] } + /// An edit, Undo or Redo made outside the tool drops the object picked first and stops adding to + /// the blend just made, so the next click picks a new first object. + fn history_moved(&mut self) { + (self.first, self.chained) = (None, false); + } } #[derive(Default)] From e1440a3a9012779be7f40cfd166349cbfdc61372 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 21:14:57 -0700 Subject: [PATCH 02/38] Paragraph: Kinsoku Shori Type chooses Push In First, Push Out First or Push Out Only The Paragraph panel menu (with the East Asian options) gains Kinsoku Shori Type, and text.setFormat takes kinsokuType. Where the Kinsoku Set keeps a character off the start of the next line or the end of this one, Push In First sets it on the line and closes the line's gaps next to CJK characters to fit, each by at most a tenth of an em of the character before it; a line that cannot close up that much, or that holds a tab, pushes out. With Optical Margin Alignment or Roman Hanging Punctuation on, the gaps that hanging punctuation keeps open are not counted. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only, the default, is the line breaking area type had before, so existing documents lay out unchanged. The setting does nothing with Kinsoku Set None, is saved only when it is not Push Out Only, and paragraph styles carry it. Reset Panel sets it back to Push Out Only. Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md. --- crates/doc/src/lib.rs | 6 +- crates/doc/src/text.rs | 4 +- crates/doc/src/text/para_style.rs | 28 +++++ crates/engine/src/cmd/panelcmds.rs | 15 ++- crates/engine/src/cmd/textstyles.rs | 1 + crates/engine/src/cmd/typecmd/area_tests.rs | 37 ++++++ crates/text/src/layout.rs | 4 +- crates/text/src/layout/breaking.rs | 124 ++++++++++++++++++- crates/text/src/layout/flow.rs | 67 +++++++--- crates/text/src/layout/wrapping_tests.rs | 88 +++++++++++-- crates/text/src/tests/vertical.rs | 51 ++++++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/paragraph.rs | 35 +++++- crates/ui-egui/src/panels/paragraph/tests.rs | 26 ++++ docs/mcp.md | 21 ++++ docs/target-app-parity.md | 1 + docs/type-parity.md | 1 + 42 files changed, 574 insertions(+), 39 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 26e2bfebf..0ff13cd31 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -108,9 +108,9 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, - JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, - RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, - WrapShape, + JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, + RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, + VerticalAlign, WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index c1a36e058..664247a30 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -17,8 +17,8 @@ pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, use char_style::yes; pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics}; pub use para_style::{ - Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, - ParaStyle, SingleWordJustify, + Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel, + Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index a257e2e0a..d78363681 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -233,6 +233,9 @@ pub struct ParaStyle { /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a /// line. pub kinsoku: Kinsoku, + /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start + /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku. + pub kinsoku_type: KinsokuShoriType, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. @@ -315,6 +318,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku: Kinsoku, #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kinsoku_type: KinsokuShoriType, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, @@ -346,6 +351,7 @@ impl From for ParaStyleFile { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, .. @@ -370,6 +376,7 @@ impl From for ParaStyleFile { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, } @@ -398,6 +405,7 @@ impl From for ParaStyle { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, .. @@ -421,6 +429,7 @@ impl From for ParaStyle { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, } @@ -485,6 +494,25 @@ pub enum Kinsoku { Soft, } +/// How a line keeps the characters [`Kinsoku`] rules out of its start and end (Kinsoku Shori +/// Type). Pushing in sets the characters that would start the next line (or that an opening +/// bracket ending this line would pull with it) on this line, with the line's gaps next to CJK +/// characters closed to make room, each by the same share of the em of the character before it +/// and by at most a tenth of that em; pushing out moves the character before them to the next +/// line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KinsokuShoriType { + /// Push in when the line can close up enough, else push out. + PushInFirst, + /// Push out, unless that would leave the line more than an em short (of the character before + /// the break): then push in when the line can close up enough. + PushOutFirst, + /// Always push out; never push in. + #[default] + PushOutOnly, +} + /// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) /// stands outside the line's measure, in the space its punctuation spacing gives it (half width /// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 7f504dcc4..83f4f72eb 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, always moves it; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -364,6 +364,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "singleWordJustification", "mojikumi", "kinsoku", + "kinsokuType", "direction", "leadingModel", "charAlign", @@ -414,6 +415,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`kinsoku` must be \"none\", \"hard\" or \"soft\"")), }), }; + let kinsoku_type = match p.get("kinsokuType") { + None => None, + Some(v) => Some(match v.as_str() { + Some("pushInFirst") => vectorcraft_doc::KinsokuShoriType::PushInFirst, + Some("pushOutFirst") => vectorcraft_doc::KinsokuShoriType::PushOutFirst, + Some("pushOutOnly") => vectorcraft_doc::KinsokuShoriType::PushOutOnly, + _ => return Err(bad(C, "`kinsokuType` must be \"pushInFirst\", \"pushOutFirst\" or \"pushOutOnly\"")), + }), + }; let direction = match p.get("direction") { None => None, Some(v) => Some(match v.as_str() { @@ -635,6 +645,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = kinsoku { para.kinsoku = v; } + if let Some(v) = kinsoku_type { + para.kinsoku_type = v; + } if let Some(v) = direction { para.direction = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 5995c490d..46c0038dd 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -201,6 +201,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("leading_model", json!(v.leading_model)), ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), + ("kinsoku_type", json!(v.kinsoku_type)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), ] { diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 65febdf0b..ab74c1708 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -98,6 +98,43 @@ fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { assert_eq!(old.kinsoku, Kinsoku::Hard); } +/// Paragraph panel menu › Kinsoku Shori Type: `text.setFormat {kinsokuType}` sets it per +/// paragraph in one undo step, refuses unknown values, saves it only when it is not Push Out Only +/// (new type's and older documents' type), reopens with the file, and paragraph styles carry it. +#[test] +fn kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only() { + use vectorcraft_doc::KinsokuShoriType; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あ」\nい", "area": {"width": 100, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushOutOnly); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kinsoku_type").is_none()); + s.execute("text.setFormat", &json!({"id": id, "start": 0, "end": 1, "kinsokuType": "pushInFirst"})).unwrap(); + assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kinsoku_type"], "pushInFirst"); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).kinsoku_type, back.para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert!(s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushIn"})).is_err()); + assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushInFirst, "an unknown value changes nothing"); + s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushOutFirst"})).unwrap(); + assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Pushed in", "attrs": {"kinsoku_type": "pushOutFirst"}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Pushed in", "id": id})).unwrap(); + assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.kinsoku_type, KinsokuShoriType::PushOutOnly); +} + #[test] fn burasagari_is_set_per_paragraph_and_saved_only_when_on() { use vectorcraft_doc::Burasagari; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f217eaa3a..2a63aa80f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -6,8 +6,8 @@ use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{ - Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, - RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, + Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, + PathAlign, PathEffect, RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, }; use vectorcraft_geom::{ArcPath, PathData}; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index b83640d02..05cb35902 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,7 +1,8 @@ use super::region::Region; +use super::vertical::glyph_em; use super::{ - Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, LeadingModel, SGlyph, hyphen_glyph, - hyphen_points_with, no_line_end, no_line_start, soft_line_start, + Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, + hyphen_glyph, hyphen_points_with, is_cjk, no_line_end, no_line_start, soft_line_start, }; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). @@ -268,7 +269,10 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { /// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when /// the line may hyphenate a word; a line whose text before the overflowing word already ends /// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop -/// (`tabs`), with the line's content starting `x_start` from the tab origin. +/// (`tabs`), with the line's content starting `x_start` from the tab origin. Where kinsoku alone +/// keeps the line from breaking before the character that overflows, `kinsoku_type` decides +/// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical +/// Margin Alignment or Roman Hanging Punctuation is on. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -280,6 +284,8 @@ pub(super) fn break_line( hyphenate: Option<(&HyphenationOptions, f64)>, burasagari: Burasagari, kinsoku: Kinsoku, + kinsoku_type: KinsokuShoriType, + hanging: bool, ) -> (usize, bool) { if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { @@ -329,6 +335,9 @@ pub(super) fn break_line( } return (end, false); } + if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging) { + return (end, false); + } break; } x += cluster_width; @@ -491,6 +500,115 @@ fn kinsoku_between(before: char, after: Option, kinsoku: Kinsoku) -> bool } } +/// The share of an em by which a push-in ([`push_in`]) may close each gap next to a CJK character +/// on the line, measured on the character before the gap. +pub(super) const PUSH_IN_SQUEEZE: f64 = 0.1; + +/// The most glyphs after the overflowing cluster a push-in ([`push_in`]) takes onto the line; a +/// longer run of characters kinsoku keeps together pushes out. +const PUSH_IN_RUN: usize = 16; + +/// Kinsoku Shori Type: the end of the line from `i` that overflows `width` at the cluster +/// `j..cluster_end` (the line is `x` wide through it) when the line pushes in, or None when it +/// pushes out. A line pushes in only where kinsoku rules out the break before `j`: a character +/// that may not end a line comes before it (an opening bracket or quotation mark, Latin ones +/// included), or one that may not start a line comes at `j`. It then takes the characters up to +/// the next break kinsoku allows (not at a soft hyphen), and the spaces after it, when closing +/// its gaps next to CJK characters, each by up to [`PUSH_IN_SQUEEZE`] of an em of the character +/// before it, makes room for them. Push In First tries this before it pushes out. Push Out First +/// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the +/// line short by more than an em of the character before `j`. Push Out Only never pushes in, and +/// neither does a line holding a tab, nor one where the characters taken in reach a space after a +/// character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging +/// Punctuation), the gaps that hanging punctuation keeps open are not counted. +#[allow(clippy::too_many_arguments)] +fn push_in( + g: &[SGlyph], + i: usize, + j: usize, + cluster_end: usize, + x: f64, + width: f64, + pushed_out: Option, + kinsoku: Kinsoku, + kinsoku_type: KinsokuShoriType, + hanging: bool, +) -> Option { + if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly { + return None; + } + let before = g.get(j.checked_sub(1)?)?; + if before.len == 0 || g.get(j)?.no_break || splits_no_break(g, j) || kinsoku_allows(g, j - 1, kinsoku) { + return None; + } + let allowed = |k: usize| { + k >= g.len() + || (g[k].byte != g[k - 1].byte + && kinsoku_allows(g, k - 1, kinsoku) + && (g[k].is_space() || (g[k - 1].break_after() && !g[k - 1].is_soft_hyphen()))) + }; + let (mut end, mut x) = (cluster_end, x); + while !allowed(end) { + let gl = &g[end]; + // A space here follows a character kinsoku keeps off a line's end: the line pushes out. + if gl.is_space() || gl.ch == '\t' || gl.ch == '\u{2028}' || gl.no_break || end - cluster_end >= PUSH_IN_RUN { + return None; + } + x += gl.adv; + end += 1; + } + // A tab's width depends on where it falls; a line with one (other than a list marker's) + // pushes out. + if g[i..end].iter().any(|h| h.ch == '\t' && h.len > 0) { + return None; + } + // With `hanging`, punctuation keeps the gap after a mark that may hang at the line's start, + // and the gap before one that may hang at its end ([`optical_hangs`]): those gaps are not + // counted. + let mut last = end; + while last > i && g[last - 1].is_space() { + last -= 1; + } + let punct = |k: usize| g.get(k).is_some_and(optical_punct); + let quote = |k: usize| g.get(k).is_some_and(|h| is_quote(h.ch)); + let may_hang = |a: usize| { + hanging + && ((a == i && punct(i)) + || (a == i + 1 && punct(i) && quote(i + 1)) + || (a + 2 == last && punct(last - 1)) + || (a + 3 == last && punct(last - 2) && quote(last - 1))) + }; + let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * glyph_em(&g[a])).sum(); + if x - width > room + EPS { + return None; + } + if kinsoku_type == KinsokuShoriType::PushOutFirst { + let mut kept = pushed_out.filter(|&b| b > i).unwrap_or(i); + while kept > i && g[kept - 1].is_space() { + kept -= 1; + } + let short = width - g[i..kept].iter().map(|h| h.adv).sum::(); + if short <= glyph_em(before) + EPS { + return None; + } + } + while g.get(end).is_some_and(SGlyph::is_space) { + end += 1; + } + if g.get(end).is_some_and(|h| h.ch == '\u{2028}') { + end += 1; + } + Some(end) +} + +/// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them +/// CJK, outside a tate-chu-yoko block and a list marker, and not next to a space, a tab or an +/// inline graphic before it. +fn closes(g: &[SGlyph], a: usize) -> bool { + let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false }; + p.len > 0 && p.inline.is_none() && !p.is_space() && !n.is_space() && p.ch != '\t' && !n.continues_tcy() && (is_cjk(p.ch) || is_cjk(n.ch)) +} + #[cfg(test)] #[test] fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 03c2eb3c4..c28508751 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -1,16 +1,22 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{ - Metrics, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, roman_share, - set_width, tab_advance, word_width, + Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, + roman_share, set_width, tab_advance, word_width, }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; use super::{ - Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, LeadingModel, LineFit, LineInfo, ListMarker, Mojikumi, ParaStyle, - Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, - is_word_space, kashida, mark_word_ends, style_metrics, + Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo, + ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, + fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +/// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, +/// with kinsoku on)? +fn pushes_in(para: &ParaStyle) -> bool { + para.kinsoku != Kinsoku::None && para.kinsoku_type != KinsokuShoriType::PushOutOnly +} + /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; @@ -32,6 +38,8 @@ fn compose_para( // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese // single-line composer does). let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); + // So is a paragraph that may push characters kinsoku keeps off a line's ends in onto a line. + let may_push_in = pushes_in(para) && sg.iter().any(|g| no_line_start(g.ch) || no_line_end(g.ch)); // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). // The tab after a list marker always starts the first line and has a fixed width. @@ -42,6 +50,7 @@ fn compose_para( || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang + || may_push_in || has_tab || has_soft_break { @@ -283,6 +292,8 @@ struct PlacementContext<'a> { zone: f64, index: usize, para_line: usize, + /// Optical Margin Alignment or Roman Hanging Punctuation is on. + hanging: bool, } struct LinePosition { @@ -291,13 +302,16 @@ struct LinePosition { x1: f64, end: usize, hyph: bool, + /// Kinsoku Shori Type pushed characters in onto the line ([`break_line`]). + pushed_in: bool, m: Metrics, ind_l: f64, ind_r: f64, } fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option { - let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line } = context; + let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line, hanging } = + context; let n = sg.len(); let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; let first_line = *para_line == 0 && !*continued; @@ -326,10 +340,16 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC }; let width = x1 - x0 - ind_l - ind_r; let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((¶.hyphenation, *zone)); - let (end, hyph) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { - Some(&(end, hyph)) if end > *i => (end, hyph), - _ if *i < n => break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), - _ => (n, false), + let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + Some(&(end, hyph)) if end > *i => (end, hyph, false), + _ if *i < n => { + let line = |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging); + let (end, hyph) = line(para.kinsoku_type); + // The line pushed characters in where it ends after the line that pushes out. + let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0; + (end, hyph, pushed_in) + } + _ => (n, false, false), }; let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { @@ -351,7 +371,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC pen.queued.clear(); continue; } - return Some(LinePosition { baseline, x0, x1, end, hyph, m, ind_l, ind_r }); + return Some(LinePosition { baseline, x0, x1, end, hyph, pushed_in, m, ind_l, ind_r }); } } @@ -379,6 +399,10 @@ struct LineJustification { per_space: f64, per_gap: f64, per_cjk: f64, + /// Kinsoku Shori Type: the share of an em (of the glyph before it, at most + /// [`PUSH_IN_SQUEEZE`]) each gap next to a CJK character closes by on a line that pushed + /// characters in; 0 on other lines. + squeeze: f64, fit: LineFit, start_x: f64, after: Vec>, @@ -413,6 +437,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut zone: paragraph.zone, index, para_line: line_in_paragraph, + hanging: t.optical_margin || paragraph.para.roman_hanging, }; let Some(position) = place_flow_line(cx, pen, &mut placement) else { return false }; hyphen_run = if position.hyph && position.end > index { hyphen_run + 1 } else { 0 }; @@ -637,7 +662,17 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' let width = line.render_width + kashidas.iter().map(|k| k.1).sum::(); let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let mut fit = LineFit::default(); - if justify && (room - width).abs() > EPS { + let mut squeeze = 0.0; + // Kinsoku Shori Type: a line of area type that pushed characters in is wider than its + // measure, and closes its gaps next to CJK characters to fit, however it is aligned: each by + // the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`]. + let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; + if pushed_in { + let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| glyph_em(&sg[j])).sum(); + if ems > EPS { + squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); + } + } else if justify && (room - width).abs() > EPS { if cjk_gaps > 0 && room > width { // Spread over the CJK gaps and the word spaces alike. per_space = (room - width) / (cjk_gaps + spaces) as f64; @@ -665,7 +700,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' } } let position = &line.position; - let start_x = if justify { + let start_x = if justify || pushed_in { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { match align { @@ -680,7 +715,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas } } fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { @@ -736,9 +771,9 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData } if glyph.is_space() { advance += justification.per_space; - } else if justification.per_cjk != 0.0 { + } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk; + advance += justification.per_cjk + justification.squeeze * glyph_em(glyph); } } else if !line.held.contains(&glyph_index) && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 360c386ea..e6b6ad08f 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -53,7 +53,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -66,11 +66,12 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); - let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (second_end, hyphenated) = + break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -125,7 +126,7 @@ fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); - let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -133,7 +134,7 @@ fn no_break_keeps_marked_characters_on_one_line() { for g in &mut marked { g.no_break = true; } - let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -142,11 +143,84 @@ fn no_break_keeps_marked_characters_on_one_line() { let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}"); } + +/// Synthetic full-width characters `text`, each 10 pt wide on a 10 pt em. +fn full_width(text: &str) -> Vec { + let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + for g in &mut glyphs { + g.sy = 10.0 / g.face.units_per_em(); + } + glyphs +} + +/// Kinsoku Shori Type, in a measure of 15 full-width characters: a closing bracket that would +/// start the next line is pushed in onto the line with Push In First, and the character before +/// it is pushed out with it with Push Out First and Push Out Only. Push Out First pushes in when +/// pushing out would leave the line two characters short (an opening bracket goes too). A line +/// that cannot close up enough pushes out, and with Kinsoku None the types do nothing. +#[test] +fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { + use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; + let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging).0 + }; + let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); + let text = "一二三四五六七八九十一二三四五」六"; + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushInFirst), 16, "」 is pushed in"); + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushOutFirst), 14, "五 is pushed out with 」"); + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushOutOnly), 14, "五 is pushed out with 」"); + assert_eq!(end(text, 150.0, Kinsoku::Soft, PushInFirst), 16, "Soft keeps 」 off the line start too"); + assert_eq!(end(text, 150.0, Kinsoku::None, PushInFirst), 15, "no kinsoku: 」 starts the next line"); + + let bracketed = "一二三四五六七八九十一二三「四」六"; + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushOutFirst), 16, "pushing out 「四 would leave two ems"); + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushInFirst), 16); + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushOutOnly), 13); + + // Latin brackets and quotation marks are pushed in too: an opening one that would end the + // line, and a closing one after a Latin letter that would start the next. + for opening in ["一二三四五六七八九十一二三四(五六", "一二三四五六七八九十一二三四\u{201c}五六"] { + assert_eq!(end(opening, 150.0, Kinsoku::Hard, PushInFirst), 16, "{opening}: the bracket and 五 are pushed in"); + assert_eq!(end(opening, 150.0, Kinsoku::Hard, PushOutOnly), 14, "{opening}: the bracket is pushed out with 五"); + } + let latin = "一二三四五六七八九十一二三四a)"; + assert_eq!(end(latin, 150.0, Kinsoku::Hard, PushInFirst), 16, ") is pushed in"); + assert_eq!(end(latin, 150.0, Kinsoku::Hard, PushOutOnly), 14, "a is pushed out with )"); + // A push-in does not end at a soft hyphen: taking `b` and the soft hyphen in would fit, the + // whole word `bc` does not, so the line pushes out. + let shy = format!("{}a)b\u{ad}c", "一".repeat(31)); + assert_eq!(end(­, 320.0, Kinsoku::Hard, PushInFirst), 31); + assert_eq!(end(­, 320.0, Kinsoku::Hard, PushOutOnly), 31); + // Nor at a space after an opening bracket, which would leave the bracket ending the line. + let spaced = format!("{}「「 A", "一".repeat(14)); + assert_eq!(end(&spaced, 150.0, Kinsoku::Hard, PushInFirst), 14); + assert_eq!(end(&spaced, 150.0, Kinsoku::Hard, PushOutOnly), 14); + // With hanging punctuation on, the gap before a full stop that may hang at the line's end is + // not counted: the other nine gaps close by 9 pt, short of the 10 pt the full stop needs. With + // it off, all ten gaps close and the full stop is pushed in. + let stop = format!("{}五.", "一".repeat(9)); + assert_eq!(hung(&stop, 100.0, Kinsoku::Hard, PushInFirst, true), 9); + assert_eq!(hung(&stop, 100.0, Kinsoku::Hard, PushOutOnly, true), 9); + assert_eq!(end(&stop, 100.0, Kinsoku::Hard, PushInFirst), 11); + assert_eq!(end(&stop, 100.0, Kinsoku::Hard, PushOutOnly), 9); + + // Two gaps close by at most 2 pt, not the 5 pt 」 needs. + let short = "一二」三"; + assert_eq!(end(short, 25.0, Kinsoku::Hard, PushInFirst), 1, "too little to close up: 二 is pushed out"); + + // A line holding a tab pushes out. Here the tab reaches the default stop at 36 pt, so pushing + // out leaves the line one em short, though the tab glyph's own advance is 10 pt. + let tabbed = "一\t二三四五六七八九十一二」六"; + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); +} diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 1010ed84a..334a91105 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -699,6 +699,57 @@ fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() { } } +/// Kinsoku Shori Type: in a measure of fifteen ems, the closing bracket after the fifteenth +/// character ends the first line with Push In First, which closes the line's gaps to keep it inside +/// the frame whatever its alignment; Push Out Only moves the fifteenth character to the next line +/// with the bracket. Horizontal and vertical. Needs a font with full-width Japanese (skipped +/// without one). +#[test] +fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { + use vectorcraft_doc::KinsokuShoriType; + let text = "一二三四五六七八九十一二三四五」六"; + let lay = |ty: KinsokuShoriType, justify: Justify, vertical_type: bool| { + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() }); + t.xf = Affine::IDENTITY; + t.vertical = vertical_type; + t.para.kinsoku_type = ty; + t.para.justify = justify; + let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 300.0) } else { Rect::new(0.0, 0.0, 300.0, 200.0) }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + layout(FontDb::global(), &t) + }; + if (lay(KinsokuShoriType::PushOutOnly, Justify::Left, false).glyphs[0].advance - 20.0).abs() > 2.0 { + return; // no font with full-width Japanese here + } + for vertical_type in [false, true] { + let lines = |ty| lay(ty, Justify::Left, vertical_type).glyphs.iter().map(|g| g.line).collect::>(); + let mut pushed_in = vec![0; 16]; + pushed_in.push(1); + assert_eq!(lines(KinsokuShoriType::PushInFirst), pushed_in, "vertical {vertical_type}"); + let mut pushed_out = vec![0; 14]; + pushed_out.extend([1, 1, 1]); + assert_eq!(lines(KinsokuShoriType::PushOutOnly), pushed_out, "vertical {vertical_type}"); + assert_eq!(lines(KinsokuShoriType::PushOutFirst), pushed_out, "vertical {vertical_type}: a push-out leaves one em"); + } + for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] { + let l = lay(KinsokuShoriType::PushInFirst, justify, false); + let (first, bracket) = (&l.glyphs[0], &l.glyphs[15]); + assert!(first.origin.x.abs() < 0.01, "{justify:?}: the line starts at the frame's left: {:?}", first.origin); + assert!(bracket.origin.x + bracket.advance <= 300.01, "{justify:?}: 」 ends inside the frame: {:?}", bracket.origin); + assert!(l.glyphs[1].origin.x < 20.0 - 0.5, "{justify:?}: the gaps close up"); + for g in &l.glyphs[..15] { + assert!(g.advance >= 18.0 - 0.01, "{justify:?}: a gap closes by at most a tenth of an em: {}", g.advance); + } + } + // A No Break run wider than the frame did not push anything in, and keeps its gaps. + let mut t = TextObject::point(Point::ZERO, "一二三四五六七八九十", CharStyle { size: 20.0, no_break: true, ..CharStyle::default() }); + t.xf = Affine::IDENTITY; + t.para.kinsoku_type = KinsokuShoriType::PushInFirst; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 150.0, 200.0).to_path(0.1)) }; + let l = layout(FontDb::global(), &t); + assert!((l.glyphs[1].origin.x - 20.0).abs() < 0.01, "{:?}", l.glyphs[1].origin); +} + /// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional /// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in /// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal` diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 801ed44b5..fd6e9459e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1859,6 +1859,10 @@ font-technology Variable Променливи Stylistic Стилистично Mojikumi Set Набор Mojikumi Kinsoku Set Набор Kinsoku + Kinsoku Shori Type Тип Kinsoku Shori + Push In First Първо прибиране + Push Out First Първо изтласкване + Push Out Only Само изтласкване Hard Твърдо Soft Меко Monitor RGB RGB на монитора diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d2e67c11d..9ea2a57c6 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1814,6 +1814,10 @@ digits Malayalam Malajalam Stylistic Stilsko Mojikumi Set Mojikumi skup Kinsoku Set Kinsoku skup + Kinsoku Shori Type Vrsta Kinsoku Shori + Push In First Prvo uvlačenje + Push Out First Prvo izbacivanje + Push Out Only Samo izbacivanje Hard Tvrdo Right Indent Desna uvlaka Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ffb752f8a..8c166c172 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2074,6 +2074,7 @@ digits Malayalam Malaiàlam Keywords: Paraules clau: Kind Tipus Kinsoku Set Conjunt Kinsoku + Kinsoku Shori Type Tipus de Kinsoku Shori Kite Estel Knife Ganivet Knockout Group Grup knockout @@ -3117,6 +3118,9 @@ digits Malayalam Malaiàlam Puppet Warp Pins Pins de deformació de titella Puppet Warp Tool Eina de deformació de titella Purple Porpra + Push In First Primer cap a dins + Push Out First Primer cap a fora + Push Out Only Només cap a fora Put back in the toolbar Torna a posar a la barra d’eines Quality Qualitat Quality {quality} Qualitat {quality} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 97ab1c407..724a6888b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1840,6 +1840,10 @@ digits Malayalam Malajálamské Stylistic Stylistický Mojikumi Set Sada Mojikumi Kinsoku Set Sada Kinsoku + Kinsoku Shori Type Typ Kinsoku Shori + Push In First Nejprve vtáhnout + Push Out First Nejprve vysunout + Push Out Only Pouze vysunout Hard Pevné Right Indent Odsazení zprava Right-to-Left Paragraph Direction Směr odstavce zprava doleva diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 1d1741662..f008f91c0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1645,6 +1645,10 @@ digits Malayalam Malayalam Stylistic Stilistisk Mojikumi Set Mojikumi-sæt Kinsoku Set Kinsoku-sæt + Kinsoku Shori Type Kinsoku Shori-type + Push In First Skub ind først + Push Out First Skub ud først + Push Out Only Kun skub ud Hard Hård Right Indent Højre indrykning Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 804542013..9fd518d98 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1914,6 +1914,10 @@ file-filter Templates Vorlagen Stylistic Stilistisch Mojikumi Set Mojikumi-Satz Kinsoku Set Kinsoku-Satz + Kinsoku Shori Type Kinsoku-Shori-Typ + Push In First Zuerst einziehen + Push Out First Zuerst hinausschieben + Push Out Only Nur hinausschieben Hard Hart Soft Weich Monitor RGB Monitor-RGB diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ce061787a..84a7b045c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2681,6 +2681,10 @@ theme Appearance Εμφάνιση Stylistic Στιλιστικό Mojikumi Set Σύνολο Mojikumi Kinsoku Set Σύνολο Kinsoku + Kinsoku Shori Type Τύπος Kinsoku Shori + Push In First Πρώτα προς τα μέσα + Push Out First Πρώτα προς τα έξω + Push Out Only Μόνο προς τα έξω Mono Μονοφωνικό Monochrome images Μονόχρωμες εικόνες Monospaced Σταθερού πλάτους diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f389ff27..cf5b9c75c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1912,6 +1912,10 @@ font-technology Variable Variables Stylistic Estilística Mojikumi Set Conjunto de mojikumi Kinsoku Set Conjunto de kinsoku + Kinsoku Shori Type Tipo de kinsoku shori + Push In First Primero hacia dentro + Push Out First Primero hacia fuera + Push Out Only Solo hacia fuera Hard Fuerte Soft Suave Monitor RGB RGB del monitor diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d3cf99cd8..f62cb8d52 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1801,6 +1801,10 @@ digits Malayalam Malajalam Stylistic Tyylitelty Mojikumi Set Mojikumi-joukko Kinsoku Set Kinsoku-joukko + Kinsoku Shori Type Kinsoku Shori -tyyppi + Push In First Ensin sisään + Push Out First Ensin ulos + Push Out Only Vain ulos Hard Tiukka Right Indent Oikea sisennys Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e82972fbc..6378b4fc0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1912,6 +1912,10 @@ font-technology Variable Variables Stylistic Stylistique Mojikumi Set Jeu de mojikumi Kinsoku Set Jeu de kinsoku + Kinsoku Shori Type Type de kinsoku shori + Push In First D’abord vers l’intérieur + Push Out First D’abord vers l’extérieur + Push Out Only Vers l’extérieur uniquement Hard Fort Soft Souple Monitor RGB RVB du moniteur diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1f7317fad..53f9bd35e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2114,6 +2114,7 @@ file-filter Templates Sablonok Keywords: Kulcsszavak: Kind Típus Kinsoku Set Kinsoku-készlet + Kinsoku Shori Type Kinsoku Shori típusa Kite Deltoid Knife Kés Knockout Group Kiütő csoport @@ -3019,6 +3020,9 @@ mark-style Japanese Japán Puppet Warp Pins Bábtorzítás tűi Puppet Warp Tool Bábtorzítás Purple Lila + Push In First Először behúzás + Push Out First Először kitolás + Push Out Only Csak kitolás Put back in the toolbar Visszahelyezés az eszköztárba Quality Minőség Quality {quality} Minőség: {quality} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b90aadc98..fc3ff2fa4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1645,6 +1645,10 @@ digits Malayalam Malayalam Stylistic Stilistik Mojikumi Set Set Mojikumi Kinsoku Set Set Kinsoku + Kinsoku Shori Type Jenis Kinsoku Shori + Push In First Dorong masuk dahulu + Push Out First Dorong keluar dahulu + Push Out Only Hanya dorong keluar Hard Keras Right Indent Indentasi kanan Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9a9709b8d..d008eddbf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1939,6 +1939,10 @@ file-filter Templates Modelli Stylistic Stilistica Mojikumi Set Set mojikumi Kinsoku Set Set kinsoku + Kinsoku Shori Type Tipo di kinsoku shori + Push In First Prima all'interno + Push Out First Prima all'esterno + Push Out Only Solo all'esterno Hard Forte Soft Debole Monitor RGB RGB monitor diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b901b9336..e9ea281ee 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -68,6 +68,10 @@ digits Malayalam マラヤーラム数字 Stylistic スタイリスティック Mojikumi Set 文字組み Kinsoku Set 禁則処理 + Kinsoku Shori Type 禁則調整方式 + Push In First 追い込み優先 + Push Out First 追い出し優先 + Push Out Only 追い出しのみ Hard 強い禁則 Soft 弱い禁則 Latin 欧文 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be58e0b36..9af41e3bc 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1548,6 +1548,10 @@ digits Malayalam მალაიალამური Stylistic სტილისტური Mojikumi Set Mojikumi-ის ნაკრები Kinsoku Set Kinsoku-ის ნაკრები + Kinsoku Shori Type Kinsoku Shori-ის ტიპი + Push In First ჯერ შეტანა + Push Out First ჯერ გატანა + Push Out Only მხოლოდ გატანა Hard მკაცრი Right Indent მარჯვენა შეწევა Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b979e2613..2d45f2911 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -61,6 +61,10 @@ digits Malayalam 말라얄람어 Stylistic 스타일 Mojikumi Set 모지쿠미 세트 Kinsoku Set 긴소쿠 세트 + Kinsoku Shori Type 긴소쿠 처리 유형 + Push In First 밀어 넣기 우선 + Push Out First 밀어내기 우선 + Push Out Only 밀어내기만 Hard 강하게 Soft 약하게 Latin 라틴어 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 60b73cb0f..007d79567 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1659,6 +1659,10 @@ digits Malayalam Malayalam Stylistic Stilistisch Mojikumi Set Mojikumi-set Kinsoku Set Kinsoku-set + Kinsoku Shori Type Kinsoku Shori-type + Push In First Eerst inschuiven + Push Out First Eerst uitschuiven + Push Out Only Alleen uitschuiven Hard Hard Right Indent Rechts inspringen Right-to-Left Paragraph Direction Alinearichting van rechts naar links diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e32cb1fa1..acfb26482 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1759,6 +1759,10 @@ digits Malayalam Malajalam Stylistic Stylistyczny Mojikumi Set Zestaw mojikumi Kinsoku Set Zestaw kinsoku + Kinsoku Shori Type Typ kinsoku shori + Push In First Najpierw wciągaj + Push Out First Najpierw wypychaj + Push Out Only Tylko wypychaj Hard Ścisły Right Indent Wcięcie z prawej Right-to-Left Paragraph Direction Kierunek akapitu od prawej do lewej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index af74d69dd..bb6b55d59 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3286,6 +3286,10 @@ font-technology Variable Variáveis Stylistic Estilístico Mojikumi Set Conjunto Mojikumi Kinsoku Set Conjunto Kinsoku + Kinsoku Shori Type Tipo de Kinsoku Shori + Push In First Primeiro para dentro + Push Out First Primeiro para fora + Push Out Only Somente para fora Hard Duro Soft Macio Monospaced Monoespaçado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c497b2ad2..74ed0d8c8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1937,6 +1937,10 @@ file-filter Templates Шаблоны Stylistic Стилистические Mojikumi Set Набор mojikumi Kinsoku Set Набор kinsoku + Kinsoku Shori Type Тип kinsoku shori + Push In First Сначала втягивать + Push Out First Сначала выталкивать + Push Out Only Только выталкивать Hard Строгий Soft Мягкий Monitor RGB RGB монитора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4b8742d00..79628cc8e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1859,6 +1859,10 @@ font-technology Variable Variabla Stylistic Stilistisk Mojikumi Set Mojikumi-uppsättning Kinsoku Set Kinsoku-uppsättning + Kinsoku Shori Type Kinsoku Shori-typ + Push In First Skjut in först + Push Out First Skjut ut först + Push Out Only Skjut endast ut Hard Hård Soft Mjuk Monitor RGB Bildskärms-RGB diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5aeae9118..b4138a6a9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1750,6 +1750,10 @@ font-technology Variable Değişken Mojikumi Mojikumi Mojikumi Set Mojikumi Seti Kinsoku Set Kinsoku Seti + Kinsoku Shori Type Kinsoku Shori Türü + Push In First Önce İçeri + Push Out First Önce Dışarı + Push Out Only Yalnızca Dışarı Hard Sert Soft Yumuşak Monitor RGB RGB'yi izleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 53fe1f462..8de9a4c33 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1933,6 +1933,10 @@ file-filter Templates Шаблони Stylistic Стилістичні Mojikumi Set Набір mojikumi Kinsoku Set Набір kinsoku + Kinsoku Shori Type Тип kinsoku shori + Push In First Спершу втягувати + Push Out First Спершу виштовхувати + Push Out Only Лише виштовхувати Hard Суворий Soft М’який Monitor RGB RGB монітора diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ea692ca64..6368d6994 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1953,6 +1953,7 @@ digits Hindi Hindi Keywords: Từ khóa: Kind Loại Kinsoku Set Bộ Kinsoku + Kinsoku Shori Type Kiểu Kinsoku Shori Knife Dao Knockout Group Nhóm Knockout Lab Lab @@ -2798,6 +2799,9 @@ mark-style Japanese Kiểu Nhật Puppet Warp Biến dạng con rối Puppet Warp Pins Ghim biến dạng con rối Puppet Warp Tool Công cụ Biến dạng con rối + Push In First Đẩy vào trước + Push Out First Đẩy ra trước + Push Out Only Chỉ đẩy ra Put back in the toolbar Đặt trở lại vào thanh công cụ Quality Chất lượng Quality {quality} Chất lượng {quality} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 431eb3355..c206cc98b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4835,6 +4835,10 @@ font-technology Variable 可变 Stylistic 风格化 Mojikumi Set 标点挤压集 Kinsoku Set Kinsoku 集 + Kinsoku Shori Type Kinsoku 处理方式 + Push In First 先推入 + Push Out First 先推出 + Push Out Only 只推出 Hard 强 Soft 弱 Saved Selection 1 已保存的选区 1 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c55958717..a6bb8c11f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2737,6 +2737,10 @@ file-filter Templates 範本 Stylistic 風格化 Mojikumi Set 標點間距 Kinsoku Set Kinsoku 集 + Kinsoku Shori Type Kinsoku 處理方式 + Push In First 先推入 + Push Out First 先推出 + Push Out Only 只推出 Hard 強 Soft 弱 Monitor RGB 顯示器 RGB diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 1952de032..5c3bc899c 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -2,12 +2,14 @@ //! Bullets and Numbered List buttons and their presets, indents, space before/after, Hyphenate, //! Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian //! options); the panel menu turns Roman Hanging Punctuation on and off, opens Justification… and -//! Hyphenation… and picks the Single-line or Every-line Composer. +//! Hyphenation…, picks the Kinsoku Shori Type (with the East Asian options) and picks the +//! Single-line or Every-line Composer. use egui::{Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ - Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, Mojikumi, NodeKind, ParaDirection, ParaStyle, SingleWordJustify, + Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, Mojikumi, NodeKind, ParaDirection, ParaStyle, + SingleWordJustify, }; use super::character::{Metric, Shared, text_style}; @@ -722,6 +724,22 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // How a line keeps the kinsoku characters off its ends: the selected paragraphs' type is + // checked, and none is when they differ. + let kinsoku_type = shared_para(app, |p| p.kinsoku_type); + ui.add_enabled_ui(has, |ui| { + ui.menu_button(format!(" {}", tl!("Kinsoku Shori Type")), |ui| { + for (label, t, key) in [ + (tl!("Push In First"), KinsokuShoriType::PushInFirst, "pushInFirst"), + (tl!("Push Out First"), KinsokuShoriType::PushOutFirst, "pushOutFirst"), + (tl!("Push Out Only"), KinsokuShoriType::PushOutOnly, "pushOutOnly"), + ] { + if menu_item(ui, label, true, kinsoku_type == Some(t)) { + set_kinsoku_type(app, key); + } + } + }); + }); } ui.separator(); let composer = shared_composer(app); @@ -738,10 +756,16 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Kinsoku Shori Type (`key`: `"pushInFirst"`, `"pushOutFirst"` or `"pushOutOnly"`) for the +/// paragraphs the panel acts on ([`format`]), in one undo step. +fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { + format(app, json!({"kinsokuType": key})); +} + /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification -/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the -/// Every-line Composer, Roman Hanging Punctuation off and no kashidas. +/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, Push +/// Out Only, the Every-line Composer, Roman Hanging Punctuation off and no kashidas. fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -754,7 +778,8 @@ fn reset_panel(app: &mut VectorcraftApp) { "hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized, "kashida": "none", "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", - "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine", + "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "kinsokuType": "pushOutOnly", + "composer": "everyLine", "romanHangingPunctuation": false }), ); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 163427da5..48af94f97 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -106,6 +106,32 @@ fn paragraph_mixed_kinsoku_and_mojikumi_show_blank_dropdowns() { assert_eq!(shared_para(&app, |p| p.mojikumi), Some(Mojikumi::LineEndHalf)); } +/// Kinsoku Shori Type: the menu's item sets the type of the paragraphs the Type tool's selection +/// touches in one undo step, and none is checked while the selected paragraphs differ. +#[test] +fn kinsoku_shori_type_sets_the_selected_paragraphs_in_one_undo_step() { + let (mut app, id) = editing_text("あ\nい\n"); + app.session.prefs.show_east_asian_options = true; + let types = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).kinsoku_type, text.para_at(1).kinsoku_type) + }; + select_range(&mut app, 0, 3); + let history = app.session.active().unwrap().history.undo.len(); + set_kinsoku_type(&mut app, "pushInFirst"); + assert_eq!(types(&app), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para(&app, |p| p.kinsoku_type), None, "mixed: no item checked"); + set_kinsoku_type(&mut app, "pushOutFirst"); + assert_eq!(types(&app), (KinsokuShoriType::PushOutFirst, KinsokuShoriType::PushOutFirst)); + assert_eq!(shared_para(&app, |p| p.kinsoku_type), Some(KinsokuShoriType::PushOutFirst)); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(types(&app), (KinsokuShoriType::PushOutOnly, KinsokuShoriType::PushOutOnly)); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/docs/mcp.md b/docs/mcp.md index ddd23ecfa..18a33f74d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4838,6 +4838,27 @@ JLREQ's level 3 line-breaking rules do (https://www.w3.org/TR/jlreq/#addendum_a3 {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsoku":"soft"}}} ``` +## Kinsoku Shori Type + +`text.setFormat {kinsokuType: "pushInFirst"|"pushOutFirst"|"pushOutOnly"}` (Paragraph panel menu › Kinsoku Shori Type, +shown with the East Asian options) sets how the area type lines of the paragraphs of the selected type (or `ids`, or the +paragraphs a `start` / `end` range touches) keep the characters of their Kinsoku Set off their start and end. When the +character that does not fit on a line may not start the next one, or the character before it may not end the line, +**Push In First** sets it on the line, with the characters up to the next break the Kinsoku Set allows. The line then +closes its gaps next to CJK characters to fit its measure, however it is aligned, each gap by the same share of the em +of the character before it and by at most a tenth of that em; a line that cannot close up that much, or that holds a +tab, pushes out instead. **Push Out First** pushes in +the same way only when pushing out would leave the line more than an em, of the character before the break, short of +its measure. **Push Out Only** (the default: new type and documents from before the setting) always moves the +character before them to the next line with them. With Kinsoku Set None the setting does nothing. A paragraph that +pushes in and holds such characters is broken line by line, as the Single-line Composer breaks it. The setting is saved +with the paragraph (`kinsoku_type` in `para`) only when it is not `pushOutOnly`, paragraph styles carry it, and Reset +Panel sets it back to Push Out Only. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} +``` + ## Hanging punctuation (burasagari) `text.setFormat {burasagari: "none"|"standard"|"forced"}` sets the Paragraph panel menu's Burasagari (None / Regular / diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c592788df..e9343afe2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -872,6 +872,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. + The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. diff --git a/docs/type-parity.md b/docs/type-parity.md index e13ad6680..190c3565b 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -57,6 +57,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Middle Eastern: Character Direction, kashidas, Middle Eastern composers, split caret | missing | | | CJK: input methods (marked text, candidate window) | done | | | CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing | +| CJK: Kinsoku Shori Type (Push In First, Push Out First, Push Out Only) | done | Paragraph panel menu › Kinsoku Shori Type (`text.setFormat {kinsokuType}`), with the East Asian options ([Formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); Push Out Only is the default, and a push-in closes the line's gaps next to CJK characters by at most a tenth of an em each (VectorCraft's own limit). Evidence: `layout::wrapping_tests::kinsoku_shori_type_pushes_a_closing_bracket_in_or_out`, `tests::vertical::kinsoku_shori_type_pushes_a_closing_bracket_in_or_out`, `typecmd::area_tests::kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only` | | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Up/Down and Left/Right settings remain. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on` | | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | From 4b663906e9935b5b7fe97756cf3c0b31ea181aa1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:53:56 -0800 Subject: [PATCH 03/38] Blend: tests for a pick dropped by Redo and for the history step A Redo made outside the Blend tool drops its first pick, as an edit or an Undo does, and two picks with no edit between them blend. History::step changes with each new step, Undo and Redo, also when an Undo and a new edit leave as many steps as before, and stays the same for a selection change and for an edit merged into an undo group. --- crates/engine/src/tests/blendopts.rs | 43 +++++++++++++++++++------- crates/engine/src/tests/history_mem.rs | 37 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 7332e18ec..366a19241 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -234,31 +234,50 @@ fn escape_drops_the_blend_tools_first_pick() { } #[test] -fn an_edit_or_undo_made_outside_the_blend_tool_drops_its_first_pick() { - for undo in [false, true] { +fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() { + for case in ["edit", "undo", "redo"] { + let undo = case == "undo"; let mut s = session(); let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + if case == "redo" { + s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + } s.select_tool("blend", ViewInfo::default()).unwrap(); let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); - // Undo takes away c, the object picked first; an edit recolors c, which is not picked. + // Undo takes away c, the object picked first; an edit or a redo recolors c, which is not picked. click(&mut s, if undo { 420.0 } else { 20.0 }, 20.0); assert!(!s.overlays(ViewInfo::default()).is_empty()); - if undo { - s.execute("edit.undo", &json!({})).unwrap(); - } else { - s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(); - } - assert!(s.overlays(ViewInfo::default()).is_empty(), "undo {undo}: the pick is not marked"); + match case { + "undo" => s.execute("edit.undo", &json!({})).unwrap(), + "redo" => s.execute("edit.redo", &json!({})).unwrap(), + _ => s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(), + }; + assert!(s.overlays(ViewInfo::default()).is_empty(), "{case}: the pick is not marked"); let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len()); click(&mut s, 220.0, 20.0); - assert_eq!(s.doc().unwrap().selection.objects, vec![b], "undo {undo}: b is picked first"); - assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "undo {undo}: the click changes no art"); + assert_eq!(s.doc().unwrap().selection.objects, vec![b], "{case}: b is picked first"); + assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "{case}: the click changes no art"); click(&mut s, if undo { 20.0 } else { 420.0 }, 20.0); let g = s.doc().unwrap().selection.objects[0]; - assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "undo {undo}"); + assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "{case}"); } } +#[test] +fn two_picks_with_no_edit_between_them_blend() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + click(&mut s, 20.0, 20.0); + // Drawing the overlays between the picks does not drop the first one. + assert!(!s.overlays(ViewInfo::default()).is_empty()); + click(&mut s, 220.0, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g).0, vec![a, b]); +} + #[test] fn removing_the_blend_the_blend_tool_adds_to_ends_adding() { let mut s = session(); diff --git a/crates/engine/src/tests/history_mem.rs b/crates/engine/src/tests/history_mem.rs index af5d49552..23665d9ec 100644 --- a/crates/engine/src/tests/history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -179,3 +179,40 @@ fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(first_x(&s), start); } + +/// `History::step` changes with every new step, Undo and Redo, also when an undo and a new edit +/// leave as many steps as before, and stays the same for an edit merged into an undo group and for +/// a selection change. +#[test] +fn the_history_step_changes_with_each_step_undo_and_redo() { + let mut s = session_with_rects(2); + let step = |s: &Session| s.doc().unwrap().history.step(); + let start = step(&s); + assert!(start.0 != 0 && start.1 == 0, "the Add step, and nothing to redo: {start:?}"); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + let moved = step(&s); + assert!(moved.0 != start.0 && moved.1 == 0, "{moved:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let undone = step(&s); + assert!(undone.0 == start.0 && undone.1 != 0, "{undone:?}"); + s.execute("edit.redo", &json!({})).unwrap(); + let redone = step(&s); + assert!(![start, moved, undone].contains(&redone) && redone.1 == 0, "{redone:?}"); + // An undo and a new edit: as many undo steps as after the move, at a different step. + s.execute("edit.undo", &json!({})).unwrap(); + let depth = s.doc().unwrap().history.undo.len(); + s.execute("object.move", &json!({"dx": 0, "dy": 1})).unwrap(); + let other = step(&s); + assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1); + assert!(![start, moved, undone, redone].contains(&other), "{other:?}"); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(step(&s), other, "a selection change is no step"); + s.execute("select.all", &json!({})).unwrap(); + s.begin_undo_group(); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + let grouped = step(&s); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(step(&s), grouped, "the second move is merged into the first"); + s.end_undo_group(false); + assert_ne!(grouped, other); +} From baeef1ec05717908707e646224ada0458b02a664 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 07:12:43 -0800 Subject: [PATCH 04/38] Blend: an edit merged into an open undo group drops the first pick An edit merged into the newest undo step (an undo group) left History::step unchanged, so a first pick made while the group was open survived the edit. The newest step now takes a new number whenever it is extended. --- crates/engine/src/history.rs | 7 ++++--- crates/engine/src/tests/blendopts.rs | 7 ++++++- crates/engine/src/tests/history_mem.rs | 16 ++++++++++------ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index ee6adccc4..4ce4e4599 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -129,8 +129,8 @@ impl History { } /// Which step the document is at: the newest undo step and the next redo step, by numbers no - /// other step takes (0 for none). Every new step, Undo and Redo changes it; an edit merged into - /// the newest step (an undo group) and a selection change do not. + /// other step takes (0 for none). Every new step, Undo, Redo and edit merged into the newest + /// step (an undo group) changes it; a selection change does not. pub fn step(&self) -> (u64, u64) { (self.undo.last().map_or(0, |e| e.serial), self.redo.last().map_or(0, |e| e.serial)) } @@ -161,11 +161,12 @@ impl History { } /// Estimate the newest undo step again, its next state now being `now` (the steps after it were - /// merged into it, or an undo group extended it). + /// merged into it, or an undo group extended it). It takes a new number ([`History::step`]). pub(crate) fn recost_last(&mut self, now: &Document) { if let Some(e) = self.undo.last_mut() { (e.bytes, e.next_bytes) = e.doc.retained_bytes_each(now); e.next = std::ptr::from_ref(now) as usize; + e.serial = next_serial(); } } } diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 366a19241..059357ddb 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -235,7 +235,7 @@ fn escape_drops_the_blend_tools_first_pick() { #[test] fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() { - for case in ["edit", "undo", "redo"] { + for case in ["edit", "undo", "redo", "group"] { let undo = case == "undo"; let mut s = session(); let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); @@ -243,6 +243,11 @@ fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() { s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); } + // In an open undo group, the edit after the pick is merged into the group's step. + if case == "group" { + s.begin_undo_group(); + s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#00ff00"})).unwrap(); + } s.select_tool("blend", ViewInfo::default()).unwrap(); let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); // Undo takes away c, the object picked first; an edit or a redo recolors c, which is not picked. diff --git a/crates/engine/src/tests/history_mem.rs b/crates/engine/src/tests/history_mem.rs index 23665d9ec..c14ea4aca 100644 --- a/crates/engine/src/tests/history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -181,8 +181,8 @@ fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() { } /// `History::step` changes with every new step, Undo and Redo, also when an undo and a new edit -/// leave as many steps as before, and stays the same for an edit merged into an undo group and for -/// a selection change. +/// leave as many steps as before, and with an edit merged into an undo group's step. It stays the +/// same for a selection change. #[test] fn the_history_step_changes_with_each_step_undo_and_redo() { let mut s = session_with_rects(2); @@ -210,9 +210,13 @@ fn the_history_step_changes_with_each_step_undo_and_redo() { s.execute("select.all", &json!({})).unwrap(); s.begin_undo_group(); s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); - let grouped = step(&s); - s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); - assert_eq!(step(&s), grouped, "the second move is merged into the first"); - s.end_undo_group(false); + let (grouped, depth) = (step(&s), s.doc().unwrap().history.undo.len()); assert_ne!(grouped, other); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "the second move is merged into the first"); + let merged = step(&s); + assert!(merged != grouped && merged.1 == 0, "a merged edit moves the step: {merged:?}"); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(step(&s), merged); + s.end_undo_group(false); } From 932426eaadd2af652b0885e48ce4c9f68466924c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:22:06 -0800 Subject: [PATCH 05/38] Type: a line holding right-to-left text pushes out A line with right-to-left text is reordered before it is drawn, and justification closes the gaps between its glyphs in that order, which can differ from the gaps the push-in counted. Such a line could be taken as fitting and then run past its frame. Kinsoku Shori Type now pushes it out. --- crates/text/src/layout/breaking.rs | 9 +++++++-- crates/text/src/layout/wrapping_tests.rs | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index e996cbba8..f41e4943f 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -518,8 +518,8 @@ const PUSH_IN_RUN: usize = 16; /// before it, makes room for them. Push In First tries this before it pushes out. Push Out First /// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the /// line short by more than an em of the character before `j`. Push Out Only never pushes in, and -/// neither does a line holding a tab, nor one where the characters taken in reach a space after a -/// character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging +/// neither does a line holding a tab or right-to-left text, nor one where the characters taken in +/// reach a space after a character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging /// Punctuation), the gaps that hanging punctuation keeps open are not counted. #[allow(clippy::too_many_arguments)] fn push_in( @@ -569,6 +569,11 @@ fn push_in( while last > i && g[last - 1].is_space() { last -= 1; } + // A line holding right-to-left text is reordered before it is drawn, and justification closes + // the gaps between its glyphs in that order: it pushes out. + if g[i..last].iter().any(|h| h.len > 0 && h.level.is_rtl()) { + return None; + } let punct = |k: usize| g.get(k).is_some_and(optical_punct); let quote = |k: usize| g.get(k).is_some_and(|h| is_quote(h.ch)); let may_hang = |a: usize| { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e6b6ad08f..ea12f601b 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -223,4 +223,13 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); + + // A line holding right-to-left text is reordered before it is drawn, so the gaps that close + // differ from the ones counted here: it pushes out. + let mixed = "一二三א五六七八九十一二三四五」六"; + let mut glyphs = full_width(mixed); + glyphs[3].level = unicode_bidi::Level::rtl(); + let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false).0; + assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); + assert_eq!(mixed_end(PushOutOnly), 14); } From 0465fb833656822627667805bc3ad03176961e0e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:36:32 -0800 Subject: [PATCH 06/38] Type: push-in closes no gap inside a cluster and measures along the line A push-in no longer counts or closes the gap between two glyphs of one shaped character, and CJK justification adds no space there either. The share of an em each gap closes by is measured along the line: at the character's Horizontal Scale, or at its Vertical Scale for an upright character in vertical type whose font's vertical metrics set its length down the column. Before, horizontal text at 200% Vertical Scale closed its gaps by twice as much. --- crates/text/src/layout/breaking.rs | 40 +++++++++++++----- crates/text/src/layout/flow.rs | 18 ++++---- crates/text/src/layout/wrapping_tests.rs | 52 +++++++++++++++++++----- crates/text/src/tests/vertical.rs | 29 +++++++++++++ 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index f41e4943f..7fbb3f39c 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,5 +1,5 @@ use super::region::Region; -use super::vertical::glyph_em; +use super::vertical::stands_upright; use super::{ Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, hyphen_glyph, hyphen_points_with, is_cjk, no_line_end, no_line_start, soft_line_start, @@ -272,7 +272,7 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { /// (`tabs`), with the line's content starting `x_start` from the tab origin. Where kinsoku alone /// keeps the line from breaking before the character that overflows, `kinsoku_type` decides /// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical -/// Margin Alignment or Roman Hanging Punctuation is on. +/// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -286,6 +286,7 @@ pub(super) fn break_line( kinsoku: Kinsoku, kinsoku_type: KinsokuShoriType, hanging: bool, + vertical: bool, ) -> (usize, bool) { if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { @@ -335,7 +336,9 @@ pub(super) fn break_line( } return (end, false); } - if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging) { + if let Some(end) = + push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging, vertical) + { return (end, false); } break; @@ -519,8 +522,9 @@ const PUSH_IN_RUN: usize = 16; /// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the /// line short by more than an em of the character before `j`. Push Out Only never pushes in, and /// neither does a line holding a tab or right-to-left text, nor one where the characters taken in -/// reach a space after a character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging -/// Punctuation), the gaps that hanging punctuation keeps open are not counted. +/// reach a space after a character that may not end a line. With `hanging` (Optical Margin +/// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not +/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). #[allow(clippy::too_many_arguments)] fn push_in( g: &[SGlyph], @@ -533,6 +537,7 @@ fn push_in( kinsoku: Kinsoku, kinsoku_type: KinsokuShoriType, hanging: bool, + vertical: bool, ) -> Option { if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly { return None; @@ -583,7 +588,7 @@ fn push_in( || (a + 2 == last && punct(last - 1)) || (a + 3 == last && punct(last - 2) && quote(last - 1))) }; - let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * glyph_em(&g[a])).sum(); + let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); if x - width > room + EPS { return None; } @@ -593,7 +598,7 @@ fn push_in( kept -= 1; } let short = width - g[i..kept].iter().map(|h| h.adv).sum::(); - if short <= glyph_em(before) + EPS { + if short <= squeeze_em(before, vertical) + EPS { return None; } } @@ -607,11 +612,26 @@ fn push_in( } /// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them -/// CJK, outside a tate-chu-yoko block and a list marker, and not next to a space, a tab or an -/// inline graphic before it. +/// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space, +/// a tab or an inline graphic before it. fn closes(g: &[SGlyph], a: usize) -> bool { let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false }; - p.len > 0 && p.inline.is_none() && !p.is_space() && !n.is_space() && p.ch != '\t' && !n.continues_tcy() && (is_cjk(p.ch) || is_cjk(n.ch)) + p.len > 0 + && p.byte != n.byte + && p.inline.is_none() + && !p.is_space() + && !n.is_space() + && p.ch != '\t' + && !n.continues_tcy() + && (is_cjk(p.ch) || is_cjk(n.ch)) +} + +/// The em a push-in measures in, along the line: `g`'s em at its Horizontal Scale, as Mojikumi's +/// spacing is, or at its Vertical Scale when it stands upright in vertical type and the font's +/// vertical metrics set its length down the column ([`super::vertical::upright_cell`]). +pub(super) fn squeeze_em(g: &SGlyph, vertical: bool) -> f64 { + let down_the_column = vertical && stands_upright(g) && g.face.vertical_glyph(g.gid).is_some(); + g.face.units_per_em() * if down_the_column { g.sy } else { g.sx } } #[cfg(test)] diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index c28508751..a76f2080c 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -1,7 +1,7 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{ Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, - roman_share, set_width, tab_advance, word_width, + roman_share, set_width, squeeze_em, tab_advance, word_width, }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; @@ -343,7 +343,8 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { - let line = |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging); + let line = + |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical); let (end, hyph) = line(para.kinsoku_type); // The line pushed characters in where it ends after the line that pushes out. let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0; @@ -595,12 +596,13 @@ fn drawn_after(index: usize, glyph: usize, after: &[Option]) -> Option], index: usize, sg: &[SGlyph]) -> bool { !held.contains(&glyph) - && drawn_after(index, glyph, after) - .and_then(|next| sg.get(glyph).zip(sg.get(next))) - .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) + && drawn_after(index, glyph, after).and_then(|next| sg.get(glyph).zip(sg.get(next))).is_some_and(|(a, b)| { + a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) + }) } // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph @@ -668,7 +670,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' // the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`]. let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { - let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| glyph_em(&sg[j])).sum(); + let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| squeeze_em(&sg[j], vertical)).sum(); if ems > EPS { squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); } @@ -773,7 +775,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData advance += justification.per_space; } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk + justification.squeeze * glyph_em(glyph); + advance += justification.per_cjk + justification.squeeze * squeeze_em(glyph, cx.vertical); } } else if !line.held.contains(&glyph_index) && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index ea12f601b..2e62e2956 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -53,7 +53,8 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + let (end, hyphenated) = + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -66,12 +67,13 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + let (first_end, _) = + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); let (second_end, hyphenated) = - break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -126,7 +128,8 @@ fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); - let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, hyphen) = + break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -134,7 +137,8 @@ fn no_break_keeps_marked_characters_on_one_line() { for g in &mut marked { g.no_break = true; } - let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, hyphen) = + break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -143,9 +147,9 @@ fn no_break_keeps_marked_characters_on_one_line() { let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); @@ -157,7 +161,8 @@ fn full_width(text: &str) -> Vec { let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); let mut glyphs = synthetic_glyphs(&specs); for g in &mut glyphs { - g.sy = 10.0 / g.face.units_per_em(); + g.sx = 10.0 / g.face.units_per_em(); + g.sy = g.sx; } glyphs } @@ -171,7 +176,7 @@ fn full_width(text: &str) -> Vec { fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { - break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging).0 + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false).0 }; let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); let text = "一二三四五六七八九十一二三四五」六"; @@ -223,13 +228,40 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); +} +/// Kinsoku Shori Type counts only the gaps justification closes: a line holding right-to-left +/// text pushes out, the gaps close by a tenth of the em at the characters' Horizontal Scale, and +/// no gap closes inside a shaped cluster. +#[test] +fn kinsoku_shori_type_counts_the_gaps_that_close() { + use KinsokuShoriType::{PushInFirst, PushOutOnly}; // A line holding right-to-left text is reordered before it is drawn, so the gaps that close // differ from the ones counted here: it pushes out. let mixed = "一二三א五六七八九十一二三四五」六"; let mut glyphs = full_width(mixed); glyphs[3].level = unicode_bidi::Level::rtl(); - let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false).0; + let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); assert_eq!(mixed_end(PushOutOnly), 14); + + // The gaps close by a tenth of the em at the characters' Horizontal Scale: at 200% Vertical + // Scale five gaps still close by 5 pt, short of the 10 pt 」 needs. + let tall = "一二三四五」六"; + let mut glyphs = full_width(tall); + for g in &mut glyphs { + g.sy *= 2.0; + } + let tall_end = |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; + assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」"); + + // No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to + // close by 5 pt, short of the 6 pt it overflows by. + let mut glyphs = full_width(tall); + glyphs[1].adv = 5.0; + let half = glyphs[1].clone(); + glyphs.insert(2, half); + let split_end = |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false).0; + assert_eq!(split_end(54.0), 5, "五 is pushed out with 」"); + assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 334a91105..95c10cefe 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -750,6 +750,35 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert!((l.glyphs[1].origin.x - 20.0).abs() < 0.01, "{:?}", l.glyphs[1].origin); } +/// Kinsoku Shori Type at 200% Vertical Scale in a column of 225 pt: the font's vertical metrics set +/// each character 40 pt long, and its five gaps close by up to 4 pt each, room for the 15 pt 」 +/// overflows by, so 」 is pushed in; Push Out Only moves 五 to the next column with it. Needs +/// craft-fonts' Shippori Mincho (skipped without it). +#[test] +fn kinsoku_shori_type_closes_a_column_by_the_em_down_the_column() { + use vectorcraft_doc::KinsokuShoriType; + if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let lay = |ty: KinsokuShoriType| { + let mut t = vertical_styled( + "一二三四五」六", + CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), v_scale: 200.0, ..CharStyle::default() }, + ); + t.para.kinsoku_type = ty; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 200.0, 225.0).to_path(0.1)) }; + layout(FontDb::global(), &t) + }; + let lines = |ty| lay(ty).glyphs.iter().map(|g| g.line).collect::>(); + let out = lay(KinsokuShoriType::PushOutOnly); + assert!((out.glyphs[0].advance - 40.0).abs() < 0.01, "40 pt down the column: {}", out.glyphs[0].advance); + assert_eq!(lines(KinsokuShoriType::PushInFirst), [0, 0, 0, 0, 0, 0, 1]); + let bracket = &lay(KinsokuShoriType::PushInFirst).glyphs[5]; + assert!(bracket.origin.y + bracket.advance <= 225.01, "」 ends inside the column: {:?} +{}", bracket.origin, bracket.advance); + assert_eq!(lines(KinsokuShoriType::PushOutOnly), [0, 0, 0, 0, 1, 1, 1]); +} + /// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional /// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in /// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal` From 5a4c3e12a5b076db5951eb1c465a976ce3193443 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 15:02:39 -0800 Subject: [PATCH 07/38] 3D (Classic): Extrude & Bevel (Classic) extrudes art with Wireframe, No Shading, Diffuse and Plastic surfaces Effect > 3D and Materials > 3D (Classic) > Extrude & Bevel (Classic)... applies `threeD.classicExtrude`, a live effect built on the Extrude & Bevel solid. It takes Extrude Depth, Cap, a bevel with Height and Bevel Extent In or Out, the Position presets, rotation and Perspective, and draws the faces with the Wireframe, No Shading, Diffuse or Plastic surface: one to ten lights, Ambient Light, Highlight Intensity and Size, Blend Steps, a Black, Custom or no Shading Color, and Draw Hidden Faces. Invalid parameters are refused with an error. --- crates/effects/src/catalog.rs | 10 +- crates/effects/src/lib.rs | 4 +- crates/effects/src/solids.rs | 20 +- crates/effects/src/solids/classic.rs | 220 ++++++++++++++++++ crates/effects/src/solids/tests.rs | 164 ++++++++++++- crates/three-d/src/lib.rs | 2 +- crates/three-d/src/shading.rs | 94 +++++++- crates/three-d/src/solid.rs | 8 +- crates/three-d/src/solid/build.rs | 10 +- crates/three-d/src/solid/inflate.rs | 2 +- crates/three-d/src/solid/tests.rs | 23 ++ crates/three-d/src/tests.rs | 1 + crates/three-d/src/tests/classic.rs | 96 ++++++++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/dialogs/effect.rs | 14 +- crates/ui-egui/src/dialogs/revolve.rs | 46 ++-- crates/ui-egui/src/dialogs/revolve/tests.rs | 62 +++++ crates/ui-egui/src/dialogs/revolve_gizmo.rs | 14 +- crates/ui-egui/src/dialogs/three_d_classic.rs | 85 +++++++ crates/ui-egui/src/i18n/bg.tsv | 11 + crates/ui-egui/src/i18n/bs.tsv | 11 + crates/ui-egui/src/i18n/ca.tsv | 11 + crates/ui-egui/src/i18n/cs.tsv | 11 + crates/ui-egui/src/i18n/da.tsv | 11 + crates/ui-egui/src/i18n/de.tsv | 11 + crates/ui-egui/src/i18n/el.tsv | 11 + crates/ui-egui/src/i18n/es.tsv | 11 + crates/ui-egui/src/i18n/fi.tsv | 11 + crates/ui-egui/src/i18n/fr.tsv | 11 + crates/ui-egui/src/i18n/hi.tsv | 11 + crates/ui-egui/src/i18n/hu.tsv | 11 + crates/ui-egui/src/i18n/id.tsv | 11 + crates/ui-egui/src/i18n/it.tsv | 11 + crates/ui-egui/src/i18n/ja.tsv | 11 + crates/ui-egui/src/i18n/ka.tsv | 11 + crates/ui-egui/src/i18n/ko.tsv | 11 + crates/ui-egui/src/i18n/nl.tsv | 11 + crates/ui-egui/src/i18n/pl.tsv | 11 + crates/ui-egui/src/i18n/pt-br.tsv | 11 + crates/ui-egui/src/i18n/ru.tsv | 11 + crates/ui-egui/src/i18n/sv.tsv | 11 + crates/ui-egui/src/i18n/tr.tsv | 11 + crates/ui-egui/src/i18n/uk.tsv | 11 + crates/ui-egui/src/i18n/vi.tsv | 11 + crates/ui-egui/src/i18n/zh-hans.tsv | 11 + crates/ui-egui/src/i18n/zh-hant.tsv | 11 + crates/ui-egui/src/menus/submenus.rs | 11 +- crates/ui-egui/src/menus/tests.rs | 16 ++ docs/effects-parity.md | 2 +- docs/mcp.md | 27 +++ 50 files changed, 1186 insertions(+), 43 deletions(-) create mode 100644 crates/effects/src/solids/classic.rs create mode 100644 crates/three-d/src/tests/classic.rs create mode 100644 crates/ui-egui/src/dialogs/three_d_classic.rs diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 844063932..50bccaa83 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -1,4 +1,4 @@ -use super::{CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; +use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; /// Catalogue entry for one effect. #[derive(Clone, Debug, PartialEq)] @@ -80,6 +80,7 @@ fn lengths_of(id: &str) -> Lengths { "path.offsetPath" => always(&["offset"]), "threeD.revolve" => always(&["offset"]), "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), + "threeD.classicExtrude" => always(&["depth", "bevelHeight"]), "threeD.inflate" => always(&["depth"]), "path.outlineStroke" => always(&["width"]), "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), @@ -198,6 +199,13 @@ pub fn effect_catalog() -> Vec { "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}), ), + g( + CLASSIC_EXTRUDE, + "Extrude & Bevel (Classic)…", + &["Effect", "3D and Materials", "3D (Classic)"], + "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", + json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}), + ), g( "convertToShape.rectangle", "Rectangle…", diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8835d769f..c457df033 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -84,8 +84,8 @@ pub use reshape::{ }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ - EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, - validate_three_d, view_options, + CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, classic_extrude_options, classic_shading, extrude_options, + has_solid, inflate_options, is_three_d, solid_art, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7025f8f6f..99b246a41 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1,5 +1,5 @@ -//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every -//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces. +//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art, +//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces. use std::sync::Arc; use serde_json::Value; @@ -14,11 +14,14 @@ use vectorcraft_doc::color::Color; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry}; +mod classic; +pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; /// The 3D effects this module draws (Revolve has its own). -pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE]; +pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE]; /// Is `id` one of the 3D and Materials effects (Revolve included)? pub fn is_three_d(id: &str) -> bool { @@ -93,6 +96,7 @@ pub fn extrude_options(p: &Value) -> Extrude { inside: flag(p, "bevelInside", false), both_sides: flag(p, "bevelBothSides", false), }), + hidden: false, } } @@ -146,6 +150,7 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> let view = view_options(p); match id { EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view), + CLASSIC_EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, classic_extrude_options(p), view), INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view), _ => vectorcraft_three_d::plane(path, rule, bounds, view), } @@ -153,6 +158,9 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> /// Validate before a command changes the document; the evaluator bounds loaded files too. pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { + if id == CLASSIC_EXTRUDE { + classic::validate(p)?; + } crate::lighting::validate(p)?; let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); if let Some(v) = p.get("bevel") @@ -322,7 +330,8 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); - let colored = view_options(&p).has_colored_lights(); + let classic = fx.id == CLASSIC_EXTRUDE; + let colored = !classic && view_options(&p).has_colored_lights(); if fx.id == ROTATE { // Every part turns about the centre of the object's own bounds (a group's members about // the centre of theirs), so they stay together. @@ -383,7 +392,8 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> .map(|(item, member, f)| { let depths: Vec = f.iter().map(|f| f.depth).collect(); let (paint, opacity, blend) = paint_of(item, member); - (depths, surface(n, f, &paint, opacity, blend, colored)) + let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + (depths, art) }) .collect(); // One order takes a plain group of faces from each solid, one face for each facet. With diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs new file mode 100644 index 000000000..730fb8e53 --- /dev/null +++ b/crates/effects/src/solids/classic.rs @@ -0,0 +1,220 @@ +//! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic +//! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading +//! color. +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer}; +use vectorcraft_geom::Rect; +use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light}; + +use crate::util::{flag, num, text}; + +pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude"; + +/// The width of a Wireframe edge, in points. +const WIRE: f64 = 0.5; + +/// The Shading Color choices: black, a custom color, or none. +pub const SHADING_COLORS: [&str; 3] = ["black", "custom", "none"]; + +/// The surface settings of Extrude & Bevel (Classic)'s parameters. +pub fn classic_shading(p: &Value) -> ClassicShading { + let d = ClassicShading::default(); + ClassicShading { + surface: ClassicSurface::parse(text(p, "surface", "plastic")).unwrap_or_default(), + ambient: num(p, "ambient", d.ambient * 100.0) / 100.0, + highlight_intensity: num(p, "highlightIntensity", d.highlight_intensity * 100.0) / 100.0, + highlight_size: num(p, "highlightSize", d.highlight_size * 100.0) / 100.0, + blend_steps: num(p, "blendSteps", f64::from(d.blend_steps)).round().clamp(1.0, 256.0) as u32, + } +} + +/// The solid of Extrude & Bevel (Classic)'s parameters: its depth, cap and bevel (as tall as it is +/// wide, added around the shape or carved into it), with the faces turned away from the viewer kept +/// for Draw Hidden Faces and for the Wireframe surface. +pub fn classic_extrude_options(p: &Value) -> Extrude { + let height = num(p, "bevelHeight", 4.0); + Extrude { + depth: num(p, "depth", 50.0), + cap: flag(p, "cap", true), + bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel { + shape, + width: height, + height, + inside: flag(p, "bevelInside", false), + ..Bevel::default() + }), + hidden: flag(p, "drawHiddenFaces", false) || classic_shading(p).surface == ClassicSurface::Wireframe, + ..Extrude::default() + } +} + +/// The color faces darken toward: black, the custom color, or none (faces keep their paint and only +/// the highlight lightens them). +fn shading_color(p: &Value) -> Option { + match text(p, "shadingColor", "black") { + "none" => None, + "custom" => Some(p.get("shadingCustom").and_then(Value::as_str).and_then(parse_hex).unwrap_or(Color::BLACK)), + _ => Some(Color::BLACK), + } +} + +fn parse_hex(s: &str) -> Option { + (s.len() == 7 && s.starts_with('#') && s.bytes().skip(1).all(|b| b.is_ascii_hexdigit())).then(|| Color::from_hex(s)).flatten() +} + +/// The lights: the `lights` list when it is given, else the one light of `lightRotation`, +/// `lightHeight`, `lightBehind` and `lightIntensity`. Each is its direction toward the light in view +/// space and its intensity (0..1). +fn lights(p: &Value) -> Vec<([f64; 3], f64)> { + if let Some(list) = crate::lighting::lights(p) { + return list.iter().map(|l| (l.direction(), l.intensity)).collect(); + } + let light = Light { + rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0), + height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1), + behind: flag(p, "lightBehind", false), + ..Light::default() + }; + vec![(light.direction(), num(p, "lightIntensity", 100.0) / 100.0)] +} + +/// The numeric parameters: (key, lowest, highest, whole numbers only). +const RANGES: [(&str, f64, f64, bool); 13] = [ + ("depth", 0.0, 2000.0, false), + ("bevelHeight", 1.0, 100.0, false), + ("rotationX", -180.0, 180.0, false), + ("rotationY", -180.0, 180.0, false), + ("rotationZ", -180.0, 180.0, false), + ("perspectiveAngle", 0.0, 160.0, false), + ("lightRotation", -180.0, 180.0, false), + ("lightHeight", 0.0, 90.0, false), + ("lightIntensity", 0.0, 100.0, false), + ("ambient", 0.0, 100.0, false), + ("highlightIntensity", 0.0, 100.0, false), + ("highlightSize", 0.0, 100.0, false), + ("blendSteps", 1.0, 256.0, true), +]; + +const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"]; + +/// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of +/// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and +/// `shadingCustom` name a surface, a shading color and a #rrggbb color. +pub fn validate(p: &Value) -> Result<(), String> { + let Some(map) = p.as_object() else { return Ok(()) }; + let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null); + let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default(); + for key in map.keys() { + if !known.contains(&key.as_str()) { + let keys = known.join(", "); + return Err(format!("unknown parameter `{key}`; the command takes: {keys}")); + } + } + for (key, min, max, whole) in RANGES { + let Some(v) = map.get(key) else { continue }; + let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) }; + if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) { + return Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})")); + } + if !(min..=max).contains(&x) { + return Err(format!("`{key}` must be between {min} and {max} (got {x})")); + } + } + for key in FLAGS { + if map.get(key).is_some_and(|v| !v.is_boolean()) { + return Err(format!("`{key}` must be true or false")); + } + } + let one_of = |key: &str, ids: &[&str]| -> Result<(), String> { + match map.get(key) { + Some(v) if !v.as_str().is_some_and(|s| ids.contains(&s)) => Err(format!("`{key}` must be one of {}", ids.join(", "))), + _ => Ok(()), + } + }; + let surfaces: Vec<&str> = ClassicSurface::ALL.iter().map(|s| s.id()).collect(); + one_of("surface", &surfaces)?; + one_of("shadingColor", &SHADING_COLORS)?; + let key = "shadingCustom"; + if map.get(key).is_some_and(|v| !v.as_str().is_some_and(|s| parse_hex(s).is_some())) { + return Err(format!("`{key}` must be a #rrggbb color")); + } + Ok(()) +} + +/// `c` with tone `tone` (0 the shading color, 1 the color itself) and highlight `spec` (toward +/// white) on top. +fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color { + let base = c.to_rgb(); + let dark = shade.map_or(base, |s| s.to_rgb()); + let (tone, spec) = (tone as f32, spec as f32); + let [r, g, b] = [0, 1, 2].map(|i| { + let mixed = dark[i] + (base[i] - dark[i]) * tone; + (mixed + (1.0 - mixed) * spec).clamp(0.0, 1.0) + }); + Color::rgb(r, g, b) +} + +/// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child +/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by +/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by +/// its own transparency, so the shading follows the gradient's coverage. +pub(super) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { + let shading = classic_shading(p); + let lights = lights(p); + let shade = shading_color(p); + let wire = shading.surface == ClassicSurface::Wireframe; + let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) }; + let paint = &paint; + let children = facets + .into_iter() + .map(|face| { + if wire { + let mut edge = StrokeLayer::new(paint.clone(), WIRE); + edge.join = vectorcraft_doc::LineJoin::Round; + return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() })); + } + let (tone, spec) = shading.tone(face.normal, &lights); + // A narrow stroke of the face's own paint closes the hairline seams between faces. + let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); + let items = match paint.color() { + // Unshaded (No Shading, no shading color, or a face in full light) with no + // highlight: the paint itself, its color space and swatch kept. + Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => { + vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))] + } + Some(c) => { + let fill = Paint::solid(shaded(c, tone, spec, shade)); + vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))] + } + // Gradients and patterns keep their paint, darkened by a layer of the shading color + // and lightened by one of white. As with the other 3D effects' pattern shadow, the + // layers cover the whole face, transparent pattern cells included. + None => { + let mut items = + vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]; + for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] { + if let Some(color) = color.filter(|_| share > 0.0) { + let mut layer = FillLayer::new(Paint::solid(color)); + layer.opacity = share as f32; + items.push(AppearanceItem::Fill(layer)); + } + } + items + } + }; + Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() })) + }) + .collect(); + let mut group = Node::group(n.id, children); + group.opacity = opacity; + group.blend = blend; + if let Some(mask) = mask { + let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0); + let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0)); + group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true))); + } + group +} diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index a9997bb5d..44204f83d 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -45,10 +45,13 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() { assert_eq!(n.path_data(), Some(&source), "{id}"); assert!(!faces(&art).is_empty(), "{id}"); // The faces are the fill's red or the stroke's black, shaded: never brighter, never another - // hue. + // hue (the classic Plastic surface adds a white highlight, tested on its own). for f in faces(&art) { let [r, g, b] = fill_rgb(f); - assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}"); + assert!( + r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE || id == CLASSIC_EXTRUDE, + "{id}: {r} {g} {b}" + ); } // The art follows the source when it moves. let before = art.visual_bounds().unwrap(); @@ -595,3 +598,160 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() { assert!(err.contains("clipping groups"), "{err}"); assert!(solid_art(&g).is_none(), "drawn as the group it is"); } + +/// The red square without its stroke, with Extrude & Bevel (Classic) and `params`. +fn classic(params: Value) -> Node { + let mut n = square_node(); + n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_))); + n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params)); + n +} + +fn classic_faces(params: Value) -> Vec { + let art = solid_art(&classic(params)).unwrap(); + faces(&art).into_iter().cloned().collect() +} + +fn close3(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4) +} + +#[test] +fn classic_surfaces_draw_edges_flat_paint_soft_light_and_a_highlight() { + // Wireframe: the edges of every face, the hidden ones too, in the paint; no fills. + let wire = classic_faces(json!({"surface": "wireframe"})); + let plastic = classic_faces(json!({})); + assert_eq!(plastic.len(), 3, "a box shows three faces"); + assert_eq!(wire.len(), 6, "and has six"); + for f in &wire { + let [AppearanceItem::Stroke(s)] = &f.appearance.items[..] else { panic!("a stroke alone: {:?}", f.appearance.items) }; + assert_eq!(s.paint, Paint::solid(RED)); + } + // No Shading: every face in the art's own paint, a CMYK color kept as it is. + assert!(classic_faces(json!({"surface": "none"})).iter().all(|f| close3(fill_rgb(f), RED.to_rgb()))); + let cmyk = Paint::solid(Color::Cmyk { c: 0.1, m: 0.9, y: 0.8, k: 0.0 }); + let mut n = classic(json!({"surface": "none"})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = cmyk.clone(); + } + for f in faces(&solid_art(&n).unwrap()) { + let AppearanceItem::Fill(fill) = &f.appearance.items[0] else { panic!("a fill first") }; + assert_eq!(fill.paint, cmyk); + } + // Diffuse: the red darkened toward black by how far each face turns from the light, so the + // faces differ but keep their hue. + let diffuse: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse"})).iter().map(fill_rgb).collect(); + assert!(diffuse.iter().all(|[r, g, b]| *r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4), "{diffuse:?}"); + assert!(diffuse.windows(2).any(|w| !close3(w[0], w[1])), "{diffuse:?}"); + // Plastic: the same faces, one of them lightened toward white by the highlight. + let shiny: Vec<[f32; 3]> = + classic_faces(json!({"highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60})).iter().map(fill_rgb).collect(); + let soft: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse", "lightHeight": 60})).iter().map(fill_rgb).collect(); + assert!(shiny.iter().zip(&soft).any(|(s, d)| s[1] > d[1] + 0.01), "{shiny:?} {soft:?}"); + assert!(shiny.iter().zip(&soft).all(|(s, d)| s[1] >= d[1] - 1e-4)); + // Without highlight, Plastic shades as Diffuse. + let dull: Vec<[f32; 3]> = classic_faces(json!({"highlightIntensity": 0, "lightHeight": 60})).iter().map(fill_rgb).collect(); + assert!(dull.iter().zip(&soft).all(|(a, b)| close3(*a, *b))); +} + +#[test] +fn classic_shading_color_blend_steps_and_lights() { + let base = json!({"surface": "diffuse", "ambient": 20}); + let with = |extra: Value| { + let mut p = base.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + classic_faces(p).iter().map(fill_rgb).collect::>() + }; + // A custom shading color darkens toward that color; None keeps the paint. + assert!(with(json!({"shadingColor": "custom", "shadingCustom": "#0000ff"})).iter().any(|c| c[2] > c[0] / 8.0 + 0.01)); + assert!(with(json!({"shadingColor": "none"})).iter().all(|c| close3(*c, RED.to_rgb()))); + // One blend step: every face fully lit or fully in shadow. + let stepped = with(json!({"blendSteps": 1, "ambient": 0})); + assert!(stepped.iter().all(|c| close3(*c, RED.to_rgb()) || close3(*c, [0.0; 3])), "{stepped:?}"); + // No light: the ambient share of the red. Two lights add up. + let dark = with(json!({"lightIntensity": 0, "blendSteps": 256})); + assert!(dark.iter().all(|c| (c[0] - 0.8 * 0.2).abs() < 0.01), "{dark:?}"); + let one = with(json!({"lights": [{"intensity": 30}], "blendSteps": 256})); + let two = with(json!({"lights": [{"intensity": 30}, {"intensity": 30}], "blendSteps": 256})); + assert!(one.iter().zip(&two).all(|(a, b)| b[0] >= a[0] - 1e-4) && one.iter().zip(&two).any(|(a, b)| b[0] > a[0] + 0.01)); + // Draw Hidden Faces keeps the faces turned away, drawn first. + assert_eq!(with(json!({"drawHiddenFaces": true})).len(), 6); +} + +#[test] +fn classic_bevel_height_extent_and_cap_shape_the_solid() { + let bounds = |p: Value| solid_art(&classic(p)).unwrap().visual_bounds().unwrap(); + let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "bevel": "classic", "bevelHeight": 10}); + let out = bounds(front.clone()); + let mut inside = front.clone(); + inside["bevelInside"] = json!(true); + // Straight on, a bevel added around the shape widens it; one carved into it keeps its size. + assert!(out.width() > 100.0 + 15.0, "{out:?}"); + let carved = bounds(inside); + assert!((carved.width() - 100.0).abs() < 2.0, "{carved:?}"); + // Hollow: no caps, so the walls seen from inside show too. + assert!(classic_faces(json!({"cap": false})).len() > classic_faces(json!({})).len()); + // The depth sets how far back the solid goes. + let side = json!({"rotationX": 0, "rotationY": 90, "rotationZ": 0}); + let mut deep = side.clone(); + deep["depth"] = json!(120); + assert!((bounds(deep).width() - 120.0).abs() < 1.0); +} + +/// A gradient fill with a transparent stop is shaded opaque and masked by its own transparency, so +/// the shading does not cover the transparent part; an opaque gradient needs no mask. +#[test] +fn classic_shading_keeps_a_gradients_transparency() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + for (end, masked) in [(0.25, true), (1.0, false)] { + let mut stop = GradientStop::new(1.0, RED); + stop.opacity = end; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), stop])); + let mut n = classic(json!({})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient)); + } + let art = solid_art(&n).unwrap(); + let surface = &art.children().unwrap()[0]; + assert_eq!(surface.extras().mask.is_some(), masked, "{end}"); + let mut opaque = true; + surface.walk(&mut |c| { + for item in &c.appearance.items { + if let AppearanceItem::Fill(f) = item + && let Paint::Gradient(g) = &f.paint + { + opaque &= g.gradient.stops.iter().all(|s| s.opacity == 1.0); + } + } + }); + assert!(opaque, "{end}: the faces are drawn opaque"); + } +} + +#[test] +fn classic_parameters_are_checked() { + let n = classic(json!({})); + let check = |p: Value| validate_solid(&n, CLASSIC_EXTRUDE, &crate::merged_params(CLASSIC_EXTRUDE, &p)); + assert!(check(json!({})).is_ok()); + assert!(check(json!({"surface": "wireframe", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 256, "bevel": "ogee"})).is_ok()); + for (p, says) in [ + (json!({"surface": "glossy"}), "`surface` must be one of wireframe, none, diffuse, plastic"), + (json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"), + (json!({"shadingCustom": "blue"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"depth": 2001}), "`depth` must be between 0 and 2000 (got 2001)"), + (json!({"bevelHeight": 0}), "`bevelHeight` must be between 1 and 100 (got 0)"), + (json!({"ambient": -1}), "`ambient` must be between 0 and 100 (got -1)"), + (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number between 1 and 256 (got 2.5)"), + (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"), + (json!({"highlightSize": "big"}), "`highlightSize` must be a number"), + (json!({"cap": "yes"}), "`cap` must be true or false"), + (json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"), + (json!({"bevel": "fancy"}), "bevel must be none or one of"), + (json!({"twist": 10}), "unknown parameter `twist`"), + (json!({"lights": []}), "lights must contain one to ten lights"), + ] { + let err = check(p.clone()).unwrap_err(); + assert!(err.starts_with(says), "{p}: {err}"); + } +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 43ec2d72a..188a87a47 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -11,7 +11,7 @@ mod visibility; pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, inflate_together, plane}; pub use visibility::{VisibleFace, visible_faces}; mod shading; -pub use shading::{LightGradient, RgbGradient, ShadeGradient}; +pub use shading::{ClassicShading, ClassicSurface, LightGradient, RgbGradient, ShadeGradient}; mod lights; pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; diff --git a/crates/three-d/src/shading.rs b/crates/three-d/src/shading.rs index b0772e7cf..d351e9749 100644 --- a/crates/three-d/src/shading.rs +++ b/crates/three-d/src/shading.rs @@ -1,7 +1,99 @@ -//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle. +//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle, and the +//! flat surface shading of the classic 3D effects. use vectorcraft_geom::{Point, Vec2}; +/// The surface of a classic 3D effect: how its faces are drawn and lit. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ClassicSurface { + /// Only the outline of every face, hidden ones included; the faces are transparent. + Wireframe, + /// Every face in the art's own paint, unlit. + NoShading, + /// Soft light: each face as bright as it faces the light. + Diffuse, + /// Diffuse light plus a glossy highlight where a face mirrors the light toward the viewer. + #[default] + Plastic, +} + +impl ClassicSurface { + pub const ALL: [ClassicSurface; 4] = [Self::Wireframe, Self::NoShading, Self::Diffuse, Self::Plastic]; + + pub fn id(self) -> &'static str { + match self { + Self::Wireframe => "wireframe", + Self::NoShading => "none", + Self::Diffuse => "diffuse", + Self::Plastic => "plastic", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|v| v.id() == s) + } +} + +/// The surface settings of a classic 3D effect. Shares are 0..1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ClassicShading { + pub surface: ClassicSurface, + /// Light every face gets, whichever way it faces. + pub ambient: f64, + /// How bright the highlight is at its center (Plastic only). + pub highlight_intensity: f64, + /// How wide the highlight spreads: 1 broad, 0 a pinpoint (Plastic only). + pub highlight_size: f64, + /// How many levels the shading and the highlight are rounded to, 1..256. + pub blend_steps: u32, +} + +impl Default for ClassicShading { + fn default() -> Self { + Self { surface: ClassicSurface::Plastic, ambient: 0.5, highlight_intensity: 0.6, highlight_size: 0.9, blend_steps: 25 } + } +} + +impl ClassicShading { + /// The shine exponent of the highlight: 2 at the largest size, 256 at the smallest. + fn shininess(self) -> f64 { + 2f64.powf(1.0 + 7.0 * (1.0 - self.highlight_size.clamp(0.0, 1.0))) + } + + /// `x` (0..1) rounded to the nearest of the blend steps' levels. + fn stepped(self, x: f64) -> f64 { + let steps = f64::from(self.blend_steps.clamp(1, 256)); + ((x.clamp(0.0, 1.0) * steps).round() / steps).clamp(0.0, 1.0) + } + + /// The tone (0 in full shadow, 1 fully lit) and the highlight (0 none, 1 white) of a face with + /// view-space unit normal `normal`, seen from the front and lit by `lights`: each a unit + /// direction toward the light and its intensity (0..1). The lights add up. Wireframe and No + /// Shading are unlit: tone 1, no highlight. + pub fn tone(self, normal: [f64; 3], lights: &[([f64; 3], f64)]) -> (f64, f64) { + if matches!(self.surface, ClassicSurface::Wireframe | ClassicSurface::NoShading) { + return (1.0, 0.0); + } + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let (mut tone, mut spec) = (self.ambient.clamp(0.0, 1.0), 0.0); + for &(light, intensity) in lights { + let intensity = if intensity.is_finite() { intensity.clamp(0.0, 1.0) } else { 0.0 }; + let lit = dot(normal, light).max(0.0); + tone += intensity * lit; + // The halfway direction between the light and the eye in front of the page. + let half = [light[0], light[1], light[2] + 1.0]; + let length = dot(half, half).sqrt(); + if self.surface == ClassicSurface::Plastic && length > 1e-12 && lit > 0.0 { + spec += intensity * (dot(normal, half) / length).max(0.0).powf(self.shininess()) * self.highlight_intensity.clamp(0.0, 1.0); + } + } + if !tone.is_finite() || !spec.is_finite() { + return (1.0, 0.0); + } + (self.stepped(tone), self.stepped(spec)) + } +} + /// Document-space gradient placement and the brightness at its two ends. Clipping a /// face must keep this placement unchanged, so newly cut edges retain their lighting. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 043e34766..337ea7b23 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -201,11 +201,14 @@ pub struct Extrude { /// Closed ends (a solid) or open ones (a hollow tube). pub cap: bool, pub bevel: Option, + /// Keep the faces of a closed solid that are turned away from the viewer (Draw Hidden Faces, + /// and the classic Wireframe surface), drawn behind the faces turned toward it. + pub hidden: bool, } impl Default for Extrude { fn default() -> Self { - Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None } + Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None, hidden: false } } } @@ -239,6 +242,9 @@ pub struct Facet { pub depth: f64, pub brightness: f64, pub illumination: [f64; 3], + /// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the + /// side the viewer sees. + pub normal: [f64; 3], /// Smooth lighting on an inflated triangle, before clipping individual RGB channels. pub rgb_gradient: Option, /// The contours in view space, before projection, for tests that measure depths. diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index c78f2a38b..f32de3e4d 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -47,6 +47,9 @@ pub(super) struct Builder<'a> { /// A closed solid hides the faces turned away from the viewer; an open one shows their /// insides. pub(super) closed: bool, + /// A closed solid keeps the faces turned away from the viewer too, with their outward normals, + /// drawn behind the others. + pub(super) hidden: bool, pub(super) facets: Vec, } @@ -83,7 +86,7 @@ impl Builder<'_> { // leave gaps. let (n, behind) = if facing > 0.0 { (n, false) - } else if self.closed && facing > -0.02 { + } else if self.closed && (facing > -0.02 || self.hidden) { (n, true) } else if self.closed { return Ok(()); @@ -111,6 +114,7 @@ impl Builder<'_> { depth, brightness: self.camera.brightness(n), illumination: crate::illumination(&self.camera.view, n, self.camera.light), + normal: [n.0, n.1, n.2], rgb_gradient: None, #[cfg(test)] view, @@ -378,7 +382,7 @@ pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, } let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![] }; let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = @@ -431,7 +435,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul check_bounds(bounds)?; let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![] }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d80fef185..4b421c207 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -106,7 +106,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![] }; let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 0f78aa712..5f04a0793 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -76,6 +76,28 @@ fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() { assert!(hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() == 4)); } +#[test] +fn hidden_faces_draw_every_face_of_the_solid_behind_the_visible_ones() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() }; + let shown = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap(); + let all = extrude(&p, FillRule::EvenOdd, b, Extrude { hidden: true, ..Extrude::default() }, v).unwrap(); + // A box: the three faces turned to the viewer, then the three turned away, drawn first. + assert_eq!(shown.len(), 3); + assert_eq!(all.len(), 6); + assert!(all.windows(2).all(|w| w[0].depth <= w[1].depth)); + // The faces turned away keep their outward normals, so a light behind the solid lights them. + assert!(all[..3].iter().all(|f| f.normal[2] < 0.0), "{:?}", all.iter().map(|f| f.normal).collect::>()); + for f in shown.iter().chain(&all) { + let [x, y, z] = f.normal; + assert!(close(x.hypot(y).hypot(z), 1.0, 1e-9), "{:?}", f.normal); + } + // The visible faces keep their normals and their place at the front. + for (front, kept) in shown.iter().rev().zip(all.iter().rev()) { + assert_eq!(front.normal, kept.normal); + } +} + #[test] fn taper_shrinks_the_back_and_twist_turns_it() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); @@ -232,6 +254,7 @@ fn malformed_and_degenerate_input_is_bounded() { taper: -3.0, cap: true, bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }), + hidden: false, }; let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap(); assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite())); diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index a164c08fe..e2b6985fb 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -1,6 +1,7 @@ use super::*; mod caps; +mod classic; mod lighting; mod multiple_lights; mod smooth; diff --git a/crates/three-d/src/tests/classic.rs b/crates/three-d/src/tests/classic.rs new file mode 100644 index 000000000..1d807b4a3 --- /dev/null +++ b/crates/three-d/src/tests/classic.rs @@ -0,0 +1,96 @@ +use crate::*; + +const TOWARD: [f64; 3] = [0.0, 0.0, 1.0]; + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +fn one(light: [f64; 3], intensity: f64) -> [([f64; 3], f64); 1] { + [(light, intensity)] +} + +#[test] +fn wireframe_and_no_shading_leave_every_face_unlit() { + for surface in [ClassicSurface::Wireframe, ClassicSurface::NoShading] { + let s = ClassicShading { surface, ..ClassicShading::default() }; + for normal in [TOWARD, [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]] { + assert_eq!(s.tone(normal, &one(TOWARD, 1.0)), (1.0, 0.0), "{surface:?}"); + } + } +} + +#[test] +fn diffuse_adds_the_light_to_the_ambient_and_has_no_highlight() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.3, blend_steps: 256, ..ClassicShading::default() }; + let (lit, spec) = s.tone(TOWARD, &one(TOWARD, 0.4)); + assert!((lit - 0.7).abs() < 1.0 / 256.0, "{lit}"); + assert_eq!(spec, 0.0); + // A face turned away from the light gets the ambient light only. + let (away, _) = s.tone(TOWARD, &one([1.0, 0.0, 0.0], 0.4)); + assert!((away - 0.3).abs() < 1.0 / 256.0, "{away}"); + // A face at 60° to the light gets half of it. + let (half, _) = s.tone(TOWARD, &one([3f64.sqrt() / 2.0, 0.0, 0.5], 0.4)); + assert!((half - 0.5).abs() < 1.0 / 256.0, "{half}"); + // The tone stays within full shadow and full light. + assert_eq!(ClassicShading { ambient: 1.0, ..s }.tone(TOWARD, &one(TOWARD, 1.0)).0, 1.0); + // Without lights a face gets the ambient light only. + assert!((s.tone(TOWARD, &[]).0 - 0.3).abs() < 1.0 / 256.0); +} + +#[test] +fn lights_add_up() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 256, ..ClassicShading::default() }; + let left = [-0.6, 0.0, 0.8]; + let right = [0.6, 0.0, 0.8]; + let both = s.tone(TOWARD, &[(left, 0.3), (right, 0.3)]).0; + let single = s.tone(TOWARD, &one(left, 0.3)).0; + assert!((both - 2.0 * single).abs() < 2.0 / 256.0, "{both} {single}"); + // A light behind the object lights the faces turned away from the viewer only. + assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).0, 0.0); + assert!(s.tone([0.0, 0.0, -1.0], &one([0.0, 0.0, -1.0], 1.0)).0 > 0.99); +} + +#[test] +fn plastic_highlight_peaks_where_a_face_mirrors_the_light_to_the_eye() { + let s = ClassicShading { highlight_intensity: 0.8, blend_steps: 256, ..ClassicShading::default() }; + let light = one([0.6, 0.0, 0.8], 1.0); + // The face halfway between the light and the eye mirrors the light straight to the viewer. + let length = 0.6f64.hypot(1.8); + let half = [0.6 / length, 0.0, 1.8 / length]; + let (_, peak) = s.tone(half, &light); + assert!((peak - 0.8).abs() < 1.0 / 256.0, "{peak}"); + let (_, aside) = s.tone([-0.6, 0.0, 0.8], &light); + assert!(aside < peak / 4.0, "{aside}"); + // A smaller highlight falls off faster. + let tilted = [0.3, 0.0, 0.954]; + let broad = s.tone(tilted, &light).1; + let narrow = ClassicShading { highlight_size: 0.1, ..s }.tone(tilted, &light).1; + assert!(narrow < broad, "{narrow} {broad}"); + // No highlight on a face the light does not reach, without Highlight Intensity, or on Diffuse. + assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).1, 0.0); + assert_eq!(ClassicShading { highlight_intensity: 0.0, ..s }.tone(half, &light).1, 0.0); + assert_eq!(ClassicShading { surface: ClassicSurface::Diffuse, ..s }.tone(half, &light).1, 0.0); +} + +#[test] +fn blend_steps_round_the_shading_to_that_many_levels() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 2, ..ClassicShading::default() }; + let tones: Vec = (0..=20).map(|k| f64::from(k) / 20.0).map(|c| s.tone(TOWARD, &one([(1.0 - c * c).sqrt(), 0.0, c], 0.5)).0).collect(); + assert!(tones.iter().all(|t| close(*t, 0.0) || close(*t, 0.5)), "{tones:?}"); + assert!(tones.iter().any(|t| close(*t, 0.0)) && tones.iter().any(|t| close(*t, 0.5)), "{tones:?}"); + // Out-of-range step counts and intensities are kept within their ranges. + for steps in [0, 100_000] { + let t = ClassicShading { blend_steps: steps, ..s }.tone(TOWARD, &one(TOWARD, f64::NAN)).0; + assert!((0.0..=1.0).contains(&t)); + } +} + +#[test] +fn surfaces_round_trip_through_their_ids() { + for surface in ClassicSurface::ALL { + assert_eq!(ClassicSurface::parse(surface.id()), Some(surface)); + } + assert_eq!(ClassicSurface::parse("glossy"), None); + assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43e274505..8f1656931 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -120,6 +120,7 @@ pub mod symbolism_options; pub mod tate_chu_yoko; mod text_export; pub mod text_import; +mod three_d_classic; mod tiff_bmp_tga; pub mod tile_edge_color; mod tools; diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index a77d0eed6..38d1f659a 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -346,15 +346,20 @@ fn choices(effect: &str, key: &str) -> Option { /// The parameters whose dialogs list their values in their own bodies, as (label, value), each /// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI -/// language: Revolve's edge, Extrude & Bevel's bevel (Rounded translated in the "3d" context -/// first), every Warp style's orientation, Rasterize's Color Model (its `document` value labeled +/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d" +/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's +/// orientation, Rasterize's Color Model (its `document` value labeled /// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and /// the channel of Levels and Curves. pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option> { let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s); Some(match (effect, key) { (vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], - (vectorcraft_effects::EXTRUDE, "bevel") => vec![ + (vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => { + vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")] + } + (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")], + (vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![ (tl!("None"), "none"), (tl!("Classic"), "classic"), (c3("Rounded"), "rounded"), @@ -915,6 +920,9 @@ mod tests { for (effect, key) in [ (vectorcraft_effects::REVOLVE, "edge"), (vectorcraft_effects::EXTRUDE, "bevel"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "surface"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"), ("warp.arc", "orientation"), ("warp.twist", "orientation"), (vectorcraft_effects::RASTERIZE, "background"), diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 7146117b0..13af7bfef 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -1,11 +1,12 @@ -//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate. -//! The object and the rotation and light gizmos live on the artboard. All document changes run -//! through the effect dialog's existing preview/commit commands. +//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate, +//! and Extrude & Bevel (Classic), whose own controls are in `three_d_classic`. The object and the +//! rotation and light gizmos live on the artboard. All document changes run through the effect +//! dialog's existing preview/commit commands. use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::{EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; use crate::state::Dialog; use crate::theme::Tokens; @@ -136,14 +137,15 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } /// `s` as the 3D effects mean it (a solid's cap, not a stroke's; a bevelled edge, not a join). -fn c3(s: &'static str) -> &'static str { +pub(super) fn c3(s: &'static str) -> &'static str { crate::i18n::tr_ctx(crate::i18n::current(), "3d", s) } -/// Extrude & Bevel and Inflate: Object, Rotation and Lighting tabs (Rotate has no Object tab). -/// The rotation gizmo shows on the first two, the light gizmo on Lighting. +/// Extrude & Bevel, Inflate and Extrude & Bevel (Classic): Object, Rotation and Lighting tabs +/// (Rotate has no Object tab). The rotation gizmo shows on the first two, the light gizmo on +/// Lighting. fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) { - let object = id == EXTRUDE || id == INFLATE; + let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE; let mut tabs = vec![]; if object { tabs.push(tl!("Object")); @@ -163,10 +165,16 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & } ui.add_space(8.0); if super::revolve_gizmo::lighting(d) { - lighting_controls(ui, d); + if id == CLASSIC_EXTRUDE { + super::three_d_classic::surface_controls(ui, d); + } else { + lighting_controls(ui, d); + } } else if object && d.str("__tab") != "rotation" { let unit = app.session.general_unit(); - if id == EXTRUDE { + if id == CLASSIC_EXTRUDE { + super::three_d_classic::object_controls(ui, d, unit); + } else if id == EXTRUDE { extrude_controls(ui, d, unit); } else { heading(ui, tl!("Object")); @@ -245,7 +253,7 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) { } /// A dropdown of `options` (value, label) for `key`, with its reset dot. -fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { +pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { let id = d.str("__effect"); let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null); let value = d.fields.get(key).cloned().unwrap_or_else(|| default.clone()); @@ -268,7 +276,7 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[( } /// A checkbox for `key`, with its reset dot. -fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { +pub(super) fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { ui.horizontal(|ui| { let on = d.bool(key); if widgets::check(ui, name, on, ui.is_enabled()) { @@ -370,11 +378,11 @@ pub(super) fn light_options(d: &Dialog) -> vectorcraft_three_d::Revolve { vectorcraft_effects::revolve_options(&super::form::params(&selected_light(d))) } -fn light_record(d: &Dialog) -> Value { +pub(super) fn light_record(d: &Dialog) -> Value { Value::Object(LIGHT_KEYS.iter().filter_map(|(key, field)| d.fields.get(*field).map(|v| ((*key).into(), v.clone()))).collect()) } -fn store_selected(d: &mut Dialog, selected: &Dialog) { +pub(super) fn store_selected(d: &mut Dialog, selected: &Dialog) { if let Some(list) = explicit_lights(d) { let index = light_index(d, list.len()); let mut list = list.clone(); @@ -397,7 +405,7 @@ pub(super) fn set_light_value(d: &mut Dialog, key: &str, value: Value) { store_selected(d, &selected); } -fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { +pub(super) fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { let mut list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]); let mut index = light_index(d, list.len()); let names: Vec<_> = (1..=list.len()).map(|i| format!("{} {i}", c3("Light"))).collect(); @@ -464,12 +472,12 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn heading(ui: &mut egui::Ui, text: &str) { +pub(super) fn heading(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text)); } -fn label(ui: &mut egui::Ui, name: &str) { +pub(super) fn label(ui: &mut egui::Ui, name: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover()); ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name); @@ -482,7 +490,7 @@ fn rail_width(ui: &egui::Ui) -> f32 { /// A value can be typed, dragged, scrubbed by its label, or moved on its slider. #[allow(clippy::too_many_arguments)] -fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { +pub(super) fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { let default = vectorcraft_effects::default_params(&d.str("__effect")).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0); let from = |n| unit.map_or(n, |u| u.from_pt(n)); let to = |n| unit.map_or(n, |u| u.to_pt(n)); @@ -570,7 +578,7 @@ fn dot(ui: &egui::Ui, p: Pos2, changed: bool) { } } -fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { +pub(super) fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); let response = ui.interact(r, Id::unique(("revolve-reset", key)), Sense::click()); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset to default"))); diff --git a/crates/ui-egui/src/dialogs/revolve/tests.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs index bbaea5255..103fbe6ad 100644 --- a/crates/ui-egui/src/dialogs/revolve/tests.rs +++ b/crates/ui-egui/src/dialogs/revolve/tests.rs @@ -297,6 +297,68 @@ fn the_other_3d_effects_show_their_options_and_position_presets() { } } +/// Extrude & Bevel (Classic): its extrude and bevel options on the Object tab, Position on the +/// Rotation tab, and the surface with its lights, highlight, blend steps and shading color on the +/// Lighting tab; OK keeps the edit as one undo step. +#[test] +fn classic_extrude_bevel_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_EXTRUDE; + let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", CLASSIC_EXTRUDE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Depth", "Cap", "Bevel Shape", "Height", "Bevel Inside"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Twist" || t == "Taper" || t == "Width"), "the newer effect's options stay out"); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "bevelHeight"))).unwrap().enabled(), "no bevel: its height is off"); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation")); + h.settle(); + for text in ["Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + h.click(h.rect("Lighting").center()); + h.settle(); + for text in [ + "Surface", + "Plastic", + "Light Intensity", + "Ambient Light", + "Highlight Intensity", + "Highlight Size", + "Blend Steps", + "Shading Color", + "Draw Hidden Faces", + ] { + h.rect(text); + } + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled()); + // The light gizmo is on for a lit surface. + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + // Diffuse has no highlight; Wireframe and No Shading take no light. + h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("diffuse")); + h.settle(); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled()); + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled()); + h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("wireframe")); + h.settle(); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled()); + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "drawHiddenFaces"))).unwrap().enabled()); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + // A custom shading color shows its color row. + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("surface".into(), json!("diffuse")); + d.fields.insert("shadingColor".into(), json!("custom")); + h.settle(); + h.rect("#000000"); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_EXTRUDE); + assert_eq!((fx.params["surface"].clone(), fx.params["shadingColor"].clone()), (json!("diffuse"), json!("custom"))); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + #[test] fn extrude_bevel_options_enable_with_a_bevel_shape() { let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0)); diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index c9194d5d0..6ef51d38e 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -20,8 +20,18 @@ pub(crate) fn lighting(d: &Dialog) -> bool { d.str("__gizmo") == "light" } +/// Does the effect take light: Shading on, or for Extrude & Bevel (Classic) a Diffuse or Plastic +/// surface? +fn lit(d: &Dialog) -> bool { + if d.str("__effect") == vectorcraft_effects::CLASSIC_EXTRUDE { + matches!(d.str("surface").as_str(), "diffuse" | "plastic") + } else { + d.bool("shade") + } +} + pub(crate) fn hint(d: &Dialog) -> &'static str { - if !d.bool("preview") || (lighting(d) && !d.bool("shade")) { + if !d.bool("preview") || (lighting(d) && !lit(d)) { tl!("Drag values or labels. Double-click a value to type.") } else if lighting(d) { tl!("Drag the light, or its rings. Shift snaps to 15°.") @@ -190,7 +200,7 @@ fn snap_height(height: f64, snap: bool) -> f64 { pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) { let light_mode = lighting(d); - if !d.bool("preview") || (light_mode && !d.bool("shade")) { + if !d.bool("preview") || (light_mode && !lit(d)) { clear(ctx); return; } diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs new file mode 100644 index 000000000..06d034fce --- /dev/null +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -0,0 +1,85 @@ +//! Extrude & Bevel (Classic)'s own controls in the compact 3D dialog: the extrude and bevel options +//! on the Object tab, and the surface with its lights, highlight, blend steps and shading color on +//! the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D effect +//! shares. + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_effects::CLASSIC_EXTRUDE; + +use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle}; +use crate::state::Dialog; + +/// Depth and cap, then the bevel: its shape, its height and whether it is added around the shape +/// or carved into it. +pub(super) fn object_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + heading(ui, tl!("Object")); + number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit)); + choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]); + ui.add_space(10.0); + heading(ui, c3("Bevel")); + choice(ui, d, "bevel", tl!("Bevel Shape"), &options("bevel")); + ui.add_enabled_ui(d.str("bevel") != "none", |ui| { + number(ui, d, "bevelHeight", tl!("Height"), (1.0, 100.0), "", 1, Some(unit)); + toggle(ui, d, "bevelInside", tl!("Bevel Inside"), false); + }); +} + +/// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic +/// only), Blend Steps and Shading Color; Draw Hidden Faces last. +pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { + choice(ui, d, "surface", tl!("Surface"), &options("surface")); + let surface = d.str("surface"); + ui.add_enabled_ui(surface == "diffuse" || surface == "plastic", |ui| { + light_list(ui, d); + let mut selected = selected_light(d); + let before = light_record(&selected); + number(ui, &mut selected, "lightIntensity", tl!("Light Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, &mut selected, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None); + number(ui, &mut selected, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None); + toggle(ui, &mut selected, "lightBehind", tl!("Light behind the object"), false); + if light_record(&selected) != before { + store_selected(d, &selected); + } + number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 100.0), "%", 1, None); + ui.add_enabled_ui(surface == "plastic", |ui| { + number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); + }); + number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); + choice(ui, d, "shadingColor", tl!("Shading Color"), &options("shadingColor")); + if d.str("shadingColor") == "custom" { + shading_custom(ui, d); + } + }); + toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); +} + +/// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label). +fn options(key: &str) -> Vec<(Value, &'static str)> { + super::effect::own_choices(CLASSIC_EXTRUDE, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() +} + +/// The custom shading color: a color button, its hex value and its reset dot. +fn shading_custom(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, tl!("Color")); + let color = d + .fields + .get("shadingCustom") + .and_then(Value::as_str) + .filter(|s| s.len() == 7 && s.starts_with('#')) + .and_then(vectorcraft_color::Color::from_hex) + .unwrap_or(vectorcraft_color::Color::BLACK); + let mut rgb = color.to_rgb_uncalibrated().map(|c| (c * 255.0).round() as u8); + if ui.color_edit_button_srgb(&mut rgb).on_hover_text(tl!("Shading Color")).changed() { + d.fields.insert("shadingCustom".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + } + ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])); + ui.add_space((ui.available_width() - 24.0).max(0.0)); + if reset_dot(ui, "shadingCustom", rgb != [0; 3], "#000000") { + d.fields.insert("shadingCustom".into(), json!("#000000")); + } + }); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f6a8debdc..b95bfc13e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1079,6 +1079,17 @@ font-technology Variable Променливи Exporting {} Експортиране на {} Export… Експортиране… Extrude & Bevel… Избутване и фасетка… + 3D (Classic) 3D (класически) + Extrude & Bevel (Classic)… Избутване и фасетка (класически)… + Surface Повърхност + Wireframe Телена рамка + No Shading Без засенчване + Plastic Пластмаса + Highlight Intensity Интензивност на отблясъка + Highlight Size Размер на отблясъка + Blend Steps Стъпки на преливане + Shading Color Цвят на засенчването + Draw Hidden Faces Изчертаване на скритите страни Eyedropper Пипета Eyedropper Applies: Пипетата прилага: Eyedropper Options Опции за пипетата diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a8db1ebea..b11a403b8 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -449,6 +449,17 @@ Vector Effects Vektorski efekti 3D and Materials 3D i materijali Extrude & Bevel… Istiskivanje i zakošenje… + 3D (Classic) 3D (klasično) + Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)… + Surface Površina + Wireframe Žičani model + No Shading Bez sjenčenja + Plastic Plastika + Highlight Intensity Intenzitet odsjaja + Highlight Size Veličina odsjaja + Blend Steps Koraci prelaza + Shading Color Boja sjenčenja + Draw Hidden Faces Crtaj skrivene strane Revolve… Rotaciono tijelo… Inflate… Napuhni… Materials… Materijali… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3898a677f..811c937b6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1492,6 +1492,17 @@ Exporting {} S’està exportant {} Export… Exporta… Extrude & Bevel… Extrudeix i bisella… + 3D (Classic) 3D (clàssic) + Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)… + Surface Superfície + Wireframe Filferro + No Shading Sense ombra + Plastic Plàstic + Highlight Intensity Intensitat de la brillantor + Highlight Size Mida de la brillantor + Blend Steps Passos de fusió + Shading Color Color de l’ombra + Draw Hidden Faces Dibuixa les cares ocultes Eyedropper Comptagotes Eyedropper Applies: El comptagotes aplica: Eyedropper Options Opcions del comptagotes diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eff1d2d34..4cc191dd9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -457,6 +457,17 @@ Vector Effects Vektorové efekty 3D and Materials 3D a materiály Extrude & Bevel… Vysunutí a zkosení… + 3D (Classic) 3D (klasické) + Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)… + Surface Povrch + Wireframe Drátový model + No Shading Bez stínování + Plastic Plast + Highlight Intensity Intenzita odlesku + Highlight Size Velikost odlesku + Blend Steps Kroky prolnutí + Shading Color Barva stínování + Draw Hidden Faces Kreslit skryté stěny Revolve… Rotační těleso… Inflate… Nafouknutí… Materials… Materiály… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index abfe581e7..67b9fd42d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -415,6 +415,17 @@ Vector Effects Vektoreffekter 3D and Materials 3D og materialer Extrude & Bevel… Ekstrudér og facet… + 3D (Classic) 3D (klassisk) + Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)… + Surface Overflade + Wireframe Trådmodel + No Shading Ingen skyggelægning + Plastic Plastik + Highlight Intensity Højlysintensitet + Highlight Size Højlysstørrelse + Blend Steps Overgangstrin + Shading Color Skyggefarve + Draw Hidden Faces Tegn skjulte flader Revolve… Omdrej… Inflate… Pust op… Materials… Materialer… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4a4142d88..1bedbc649 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1079,6 +1079,17 @@ font-technology Variable Variabel Exporting {} {} wird exportiert Export… Exportieren… Extrude & Bevel… Extrudieren und abgeflachte Kante… + 3D (Classic) 3D (klassisch) + Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)… + Surface Oberfläche + Wireframe Drahtmodell + No Shading Keine Schattierung + Plastic Kunststoff + Highlight Intensity Glanzpunktintensität + Highlight Size Glanzpunktgröße + Blend Steps Angleichungsstufen + Shading Color Schattierungsfarbe + Draw Hidden Faces Verdeckte Flächen zeichnen Eyedropper Pipette Sample Color from Screen Farbe vom Bildschirm aufnehmen This system has no screen color sampler Dieses System hat keine Farbaufnahme vom Bildschirm diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6b2a022d5..c633b775d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1052,6 +1052,17 @@ font-technology Variable Μεταβλητές Exporting {} Εξαγωγή {} Export… Εξαγωγή… Extrude & Bevel… Εξώθηση και λοξότμηση… + 3D (Classic) 3D (κλασικό) + Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)… + Surface Επιφάνεια + Wireframe Συρματόπλεγμα + No Shading Χωρίς σκίαση + Plastic Πλαστικό + Highlight Intensity Ένταση ανταύγειας + Highlight Size Μέγεθος ανταύγειας + Blend Steps Βήματα ανάμειξης + Shading Color Χρώμα σκίασης + Draw Hidden Faces Σχεδίαση κρυφών εδρών Eyedropper Σταγονόμετρο (Eyedropper Tool) Eyedropper Applies: Το σταγονόμετρο εφαρμόζει: Eyedropper Options Επιλογές σταγονόμετρου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6ccd50b46..1ff0790d5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1108,6 +1108,17 @@ font-technology Variable Variables Exporting {} Exportando {} Export… Exportar… Extrude & Bevel… Extrusión y biselado… + 3D (Classic) 3D (clásico) + Extrude & Bevel (Classic)… Extrusión y biselado (clásico)… + Surface Superficie + Wireframe Malla + No Shading Sin sombreado + Plastic Plástico + Highlight Intensity Intensidad del brillo + Highlight Size Tamaño del brillo + Blend Steps Pasos de fusión + Shading Color Color de sombreado + Draw Hidden Faces Dibujar caras ocultas Eyedropper Cuentagotas Sample Color from Screen Tomar color de la pantalla This system has no screen color sampler Este sistema no tiene muestreador de color de la pantalla diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3cf8607c0..31dc31927 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -454,6 +454,17 @@ Vector Effects Vektoritehosteet 3D and Materials 3D ja materiaalit Extrude & Bevel… Pursota ja viistä… + 3D (Classic) 3D (perinteinen) + Extrude & Bevel (Classic)… Pursota ja viistä (perinteinen)… + Surface Pinta + Wireframe Rautalanka + No Shading Ei varjostusta + Plastic Muovi + Highlight Intensity Kiillon voimakkuus + Highlight Size Kiillon koko + Blend Steps Sulautusvaiheet + Shading Color Varjostusväri + Draw Hidden Faces Piirrä piilotetut pinnat Revolve… Pyöräytä… Inflate… Täytä ilmalla… Materials… Materiaalit… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67a7cb29c..8d7fa4b70 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1103,6 +1103,17 @@ font-technology Variable Variables Exporting {} Exportation de {} Export… Exporter… Extrude & Bevel… Extrusion et biseautage… + 3D (Classic) 3D (classique) + Extrude & Bevel (Classic)… Extrusion et biseautage (classique)… + Surface Surface + Wireframe Fil de fer + No Shading Sans ombrage + Plastic Plastique + Highlight Intensity Intensité de la brillance + Highlight Size Taille de la brillance + Blend Steps Étapes de transition + Shading Color Couleur d’ombrage + Draw Hidden Faces Dessiner les faces cachées Eyedropper Pipette Sample Color from Screen Prélever une couleur à l’écran This system has no screen color sampler Ce système n’a pas d’outil de prélèvement de couleur à l’écran diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e1af82a3a..bda7cd219 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1665,6 +1665,17 @@ Extension Lines एक्सटेंशन लाइन Extension: विस्तार: Extrude & Bevel… एक्सट्रूडेड और बेवेल… + 3D (Classic) 3D (क्लासिक) + Extrude & Bevel (Classic)… एक्सट्रूडेड और बेवेल (क्लासिक)… + Surface सतह + Wireframe वायरफ़्रेम + No Shading कोई शेडिंग नहीं + Plastic प्लास्टिक + Highlight Intensity हाइलाइट तीव्रता + Highlight Size हाइलाइट आकार + Blend Steps ब्लेंड चरण + Shading Color शेडिंग रंग + Draw Hidden Faces छिपे हुए फलक बनाएँ Eyedropper आईड्रॉपर Eyedropper Applies: आईड्रॉपपर लागू होता है: Eyedropper Options आईड्रॉपर ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 82f75da72..bbb808b73 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1388,6 +1388,17 @@ font-technology Variable Változó Exporting {} Exportálás: {} Export… Exportálás… Extrude & Bevel… Kihúzás és élletörés… + 3D (Classic) 3D (klasszikus) + Extrude & Bevel (Classic)… Kihúzás és élletörés (klasszikus)… + Surface Felület + Wireframe Drótváz + No Shading Nincs árnyékolás + Plastic Műanyag + Highlight Intensity Csillanás erőssége + Highlight Size Csillanás mérete + Blend Steps Átmenetlépések + Shading Color Árnyékolás színe + Draw Hidden Faces Rejtett lapok rajzolása Eyedropper Pipetta Sample Color from Screen Szín mintavétele a képernyőről This system has no screen color sampler Ezen a rendszeren nincs képernyős színmintavevő diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7230b6bfa..70b8fca6d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -415,6 +415,17 @@ Vector Effects Efek vektor 3D and Materials 3D dan material Extrude & Bevel… Ekstrusi & bevel… + 3D (Classic) 3D (klasik) + Extrude & Bevel (Classic)… Ekstrusi & bevel (klasik)… + Surface Permukaan + Wireframe Kerangka kawat + No Shading Tanpa pengarsiran + Plastic Plastik + Highlight Intensity Intensitas kilau + Highlight Size Ukuran kilau + Blend Steps Langkah campuran + Shading Color Warna pengarsiran + Draw Hidden Faces Gambar sisi tersembunyi Revolve… Revolusi… Inflate… Gelembungkan… Materials… Material… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 723fa4ab0..6d2b7fd14 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1109,6 +1109,17 @@ font-technology Variable Variabili Exporting {} Esportazione di {} in corso Export… Esporta… Extrude & Bevel… Estrusione e smusso… + 3D (Classic) 3D (classico) + Extrude & Bevel (Classic)… Estrusione e smusso (classico)… + Surface Superficie + Wireframe Reticolo + No Shading Nessuna ombreggiatura + Plastic Plastica + Highlight Intensity Intensità lucentezza + Highlight Size Dimensione lucentezza + Blend Steps Passaggi di fusione + Shading Color Colore ombreggiatura + Draw Hidden Faces Disegna facce nascoste Eyedropper Contagocce Sample Color from Screen Campiona colore dallo schermo This system has no screen color sampler Questo sistema non ha un campionatore di colore dello schermo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8bb101269..77e5ac59a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -488,6 +488,17 @@ digits Malayalam マラヤーラム数字 Vector Effects ベクター効果 3D and Materials 3D とマテリアル Extrude & Bevel… 押し出しとベベル… + 3D (Classic) 3D(クラシック) + Extrude & Bevel (Classic)… 押し出しとベベル(クラシック)… + Surface 表面 + Wireframe ワイヤーフレーム + No Shading 陰影なし + Plastic プラスチック + Highlight Intensity ハイライトの強さ + Highlight Size ハイライトのサイズ + Blend Steps ブレンドの階調 + Shading Color 陰影のカラー + Draw Hidden Faces 隠れた面を描画 Revolve… 回転体… Inflate… 膨張… Materials… マテリアル… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 343b415a4..46385a71b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -381,6 +381,17 @@ Vector Effects ვექტორული ეფექტები 3D and Materials 3D და მასალები Extrude & Bevel… ამოწევა და წაკვეთა… + 3D (Classic) 3D (კლასიკური) + Extrude & Bevel (Classic)… ამოწევა და წაკვეთა (კლასიკური)… + Surface ზედაპირი + Wireframe მავთულის ჩარჩო + No Shading დაჩრდილვის გარეშე + Plastic პლასტიკური + Highlight Intensity ელვარების ინტენსივობა + Highlight Size ელვარების ზომა + Blend Steps შერევის საფეხურები + Shading Color დაჩრდილვის ფერი + Draw Hidden Faces დამალული წახნაგების დახატვა Revolve… ბრუნვა… Inflate… გაბერვა… Materials… მასალები… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f4cc45b82..c89224001 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -481,6 +481,17 @@ digits Malayalam 말라얄람어 Vector Effects 벡터 효과 3D and Materials 3D 및 재질 Extrude & Bevel… 돌출 및 경사… + 3D (Classic) 3D(클래식) + Extrude & Bevel (Classic)… 돌출 및 경사(클래식)… + Surface 표면 + Wireframe 와이어프레임 + No Shading 셰이딩 없음 + Plastic 플라스틱 + Highlight Intensity 하이라이트 강도 + Highlight Size 하이라이트 크기 + Blend Steps 혼합 단계 + Shading Color 셰이딩 색상 + Draw Hidden Faces 숨겨진 면 그리기 Revolve… 회전체… Inflate… 부풀리기… Materials… 재질… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2e8d6f34c..cb54cffb4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -429,6 +429,17 @@ Vector Effects Vectoreffecten 3D and Materials 3D en materialen Extrude & Bevel… Extruderen en afschuinen… + 3D (Classic) 3D (klassiek) + Extrude & Bevel (Classic)… Extruderen en afschuinen (klassiek)… + Surface Oppervlak + Wireframe Draadmodel + No Shading Geen arcering + Plastic Plastic + Highlight Intensity Glansintensiteit + Highlight Size Glansgrootte + Blend Steps Overvloeistappen + Shading Color Arceringskleur + Draw Hidden Faces Verborgen vlakken tekenen Revolve… Draaien… Inflate… Opblazen… Materials… Materialen… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c9e1256c3..0972f8ebf 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -417,6 +417,17 @@ Vector Effects Efekty wektorowe 3D and Materials 3D i materiały Extrude & Bevel… Wyciągnięcie i fazowanie… + 3D (Classic) 3D (klasyczne) + Extrude & Bevel (Classic)… Wyciągnięcie i fazowanie (klasyczne)… + Surface Powierzchnia + Wireframe Szkielet + No Shading Bez cieniowania + Plastic Plastik + Highlight Intensity Intensywność połysku + Highlight Size Rozmiar połysku + Blend Steps Kroki przejścia + Shading Color Kolor cieniowania + Draw Hidden Faces Rysuj ukryte ściany Revolve… Obrót… Inflate… Nadmuchanie… Materials… Materiały… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 062b7d88b..de3b6bad2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1001,6 +1001,17 @@ Export: Exportar: Export… Exportar… Extrude & Bevel… Extrusão e Bisel… + 3D (Classic) 3D (Clássico) + Extrude & Bevel (Classic)… Extrusão e Bisel (Clássico)… + Surface Superfície + Wireframe Aramado + No Shading Sem Sombreamento + Plastic Plástico + Highlight Intensity Intensidade do Realce + Highlight Size Tamanho do Realce + Blend Steps Etapas de Mesclagem + Shading Color Cor do Sombreamento + Draw Hidden Faces Desenhar Faces Ocultas Eyedropper Conta-gotas Eyedropper Applies: O Conta-gotas Aplica: Eyedropper Options Opções do Conta-gotas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a83d9edf3..6c8d9e3c7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1106,6 +1106,17 @@ font-technology Variable Вариативные Exporting {} Экспорт {} Export… Экспорт… Extrude & Bevel… Выдавливание и фаска… + 3D (Classic) 3D (классические) + Extrude & Bevel (Classic)… Выдавливание и фаска (классическое)… + Surface Поверхность + Wireframe Каркас + No Shading Без затенения + Plastic Пластик + Highlight Intensity Интенсивность блика + Highlight Size Размер блика + Blend Steps Шаги перехода + Shading Color Цвет затенения + Draw Hidden Faces Рисовать скрытые грани Eyedropper Пипетка Sample Color from Screen Взять цвет с экрана This system has no screen color sampler В этой системе нет средства выбора цвета с экрана diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 780d23bea..bb0626540 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1087,6 +1087,17 @@ font-technology Variable Variabla Exporting {} Exporterar {} Export… Exportera… Extrude & Bevel… Extrudera och fasa… + 3D (Classic) 3D (klassisk) + Extrude & Bevel (Classic)… Extrudera och fasa (klassisk)… + Surface Yta + Wireframe Trådram + No Shading Ingen skuggning + Plastic Plast + Highlight Intensity Högdagerintensitet + Highlight Size Högdagerstorlek + Blend Steps Övergångssteg + Shading Color Skuggningsfärg + Draw Hidden Faces Rita dolda ytor Eyedropper Pipett Eyedropper Applies: Pipetten tillämpar: Eyedropper Options Pipettalternativ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 55a9cdc73..908872b50 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1079,6 +1079,17 @@ font-technology Variable Değişken Exporting {} {} dışa aktarılıyor Export… İhracat… Extrude & Bevel… Ekstrüzyon ve Eğim… + 3D (Classic) 3D (Klasik) + Extrude & Bevel (Classic)… Ekstrüzyon ve Eğim (Klasik)… + Surface Yüzey + Wireframe Tel Kafes + No Shading Gölgeleme Yok + Plastic Plastik + Highlight Intensity Parlama Yoğunluğu + Highlight Size Parlama Boyutu + Blend Steps Karışım Adımları + Shading Color Gölgeleme Rengi + Draw Hidden Faces Gizli Yüzleri Çiz Eyedropper Damlalık Sample Color from Screen Ekrandan Örnek Renk This system has no screen color sampler Bu sistemin ekran rengi örnekleyicisi yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbfc332f9..0690a9362 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1103,6 +1103,17 @@ font-technology Variable Варіативні Exporting {} Експорт {} Export… Експорт… Extrude & Bevel… Видавлювання та фаска… + 3D (Classic) 3D (класичні) + Extrude & Bevel (Classic)… Видавлювання та фаска (класичне)… + Surface Поверхня + Wireframe Каркас + No Shading Без затінення + Plastic Пластик + Highlight Intensity Інтенсивність відблиску + Highlight Size Розмір відблиску + Blend Steps Кроки переходу + Shading Color Колір затінення + Draw Hidden Faces Малювати приховані грані Eyedropper Піпетка Sample Color from Screen Взяти колір з екрана This system has no screen color sampler У цій системі немає засобу вибору кольору з екрана diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 47915fb17..3df8d6f2c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1265,6 +1265,17 @@ font-technology Variable Biến thiên Exporting {} Đang xuất {} Export… Xuất… Extrude & Bevel… Kéo dài & vát… + 3D (Classic) 3D (cổ điển) + Extrude & Bevel (Classic)… Kéo dài & vát (cổ điển)… + Surface Bề mặt + Wireframe Khung dây + No Shading Không tô bóng + Plastic Nhựa + Highlight Intensity Cường độ điểm sáng + Highlight Size Kích thước điểm sáng + Blend Steps Số bước hòa trộn + Shading Color Màu tô bóng + Draw Hidden Faces Vẽ các mặt ẩn Eyedropper Ống hút màu Sample Color from Screen Lấy mẫu màu từ màn hình This system has no screen color sampler Hệ thống này không có công cụ lấy mẫu màu trên màn hình diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e646c956b..46ea58b15 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1049,6 +1049,17 @@ Exporting {} 正在导出 {} Export… 导出… Extrude & Bevel… 突出与斜角… + 3D (Classic) 3D (经典) + Extrude & Bevel (Classic)… 突出与斜角 (经典)… + Surface 表面 + Wireframe 线框 + No Shading 无底纹 + Plastic 塑料 + Highlight Intensity 高光强度 + Highlight Size 高光大小 + Blend Steps 混合步骤 + Shading Color 底纹颜色 + Draw Hidden Faces 绘制隐藏表面 Eyedropper 吸管 Sample Color from Screen 从屏幕取样颜色 This system has no screen color sampler 此系统没有屏幕取色工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 468ad851a..36f3c4511 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1073,6 +1073,17 @@ font-technology Variable 可變 Exporting {} 正在匯出 {} Export… 匯出… Extrude & Bevel… 突出與斜角… + 3D (Classic) 3D (經典) + Extrude & Bevel (Classic)… 突出與斜角 (經典)… + Surface 表面 + Wireframe 線框 + No Shading 無網底 + Plastic 塑膠 + Highlight Intensity 亮部強度 + Highlight Size 亮部大小 + Blend Steps 漸變階數 + Shading Color 網底顏色 + Draw Hidden Faces 繪製隱藏表面 Eyedropper 滴管 Sample Color from Screen 從螢幕取樣顏色 This system has no screen color sampler 此系統沒有螢幕取色工具 diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs index a878f7f0c..76fb47e90 100644 --- a/crates/ui-egui/src/menus/submenus.rs +++ b/crates/ui-egui/src/menus/submenus.rs @@ -108,6 +108,9 @@ pub(super) fn font_items(english_names: bool) -> Vec { items.3.clone() } +/// The catalogue's menu path of the effects in 3D and Materials › 3D (Classic). +const CLASSIC_3D: &[&str] = &["Effect", "3D and Materials", "3D (Classic)"]; + /// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the /// vector effects): (submenu, the catalogue's menu path of its effects). /// The raster Stylize submenu shares its path with the vector one and lists only the effects at @@ -190,6 +193,10 @@ pub(super) fn effect_menu() -> Vec { _ => 9, }); items.push(todo("Materials…")); + let classic: Vec = cat.iter().filter(|e| e.menu == CLASSIC_3D).map(item).collect(); + if !classic.is_empty() { + items.push(sub("3D (Classic)", classic)); + } } out.push(sub(sub_name, items)); } @@ -210,7 +217,9 @@ pub(super) fn effect_menu() -> Vec { out.extend(raster); } // Anything not placed above (future effects) still shows up. - let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path); + let placed = |e: &vectorcraft_effects::EffectInfo| { + top_level(e) || in_vector_menu(e) || e.menu == CLASSIC_3D || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path) + }; for e in cat.iter().filter(|e| !placed(e)) { out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3918b9fb1..82c8e809f 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -354,6 +354,22 @@ fn saved_views_store_and_restore_the_view() { assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2"))); } +/// Effect › 3D and Materials › 3D (Classic) › Extrude & Bevel (Classic)… opens its effect dialog, +/// after Materials…, and nowhere else. +#[test] +fn classic_extrude_bevel_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicExtrude")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Extrude & Bevel (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Materials…").unwrap() < at("Extrude & Bevel (Classic)…").unwrap()); +} + /// A document's 25 views are all listed at the bottom of the View menu, by name, and the last /// one brings back its zoom, centre and rotation after the view changed. #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2307add80..66e1b713e 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -181,7 +181,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | none | diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..168c377b8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2165,6 +2165,33 @@ All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front p `perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets, the scope and what differs from the public documentation. +## Extrude & Bevel (Classic) + +`effect.apply {effect: "threeD.classicExtrude", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Extrude & Bevel (Classic)…. It builds the same solid as `threeD.extrude` and draws its faces with the +classic surfaces. Parameters: `depth` (points, 0..2000, default 50), `cap` (true solid, false hollow), `bevel` (`none`, +`classic`, `rounded`, `cove`, `step`, `ogee`), `bevelHeight` (points, 1..100, default 4; the bevel is as wide as it +is tall), `bevelInside` (false adds the bevel around the shape, true carves it into the shape), `rotationX/Y/Z` and +`perspectiveAngle` as above, and the surface: + +- `surface`: `wireframe` (the edges of every face, hidden faces included, with no fill), `none` (the art's own paint, + unlit), `diffuse` (soft light) or `plastic` (soft light plus a highlight; the default). +- `lightRotation`, `lightHeight`, `lightBehind` and `lightIntensity` (0..100%, default 100) place one light, or + `lights` holds one to ten (`intensity`, `rotation`, `height`, `behind`; their light adds up, and `color` and + `softness` are not used). `ambient` (0..100%, default 50) lights every face. +- `highlightIntensity` (0..100%, default 60) and `highlightSize` (0..100%, default 90; 100 is broad) shape the Plastic + highlight. `blendSteps` (whole number, 1..256, default 25) is how many levels the shading is rounded to. +- `shadingColor`: `black` (the default), `custom` (darkens toward `shadingCustom`, a `#rrggbb` color) or `none` (the + paint is kept and only the highlight lightens it). `drawHiddenFaces` also draws the faces turned away from the + viewer, behind the others. + +The defaults are VectorCraft's own. An unknown parameter, a number out of range, a flag that is not true or false, or +an unknown surface or shading color is refused with an error and the document is not changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":40,"bevel":"rounded","bevelHeight":6,"surface":"diffuse","blendSteps":64}}}} +``` + ## Edit Colors and Recolor Artwork From 20accc594320e88e74c31c8a3d52b15576507328 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 16:00:09 -0800 Subject: [PATCH 08/38] Docs: 3D remaining-work lists name what 3D (Classic) still lacks Extrude & Bevel (Classic) ships, so the remaining-work lists in effects-parity.md, gaps.md (G3), target-app-parity.md and three-d.md name Revolve (Classic), Rotate (Classic) and Map Art instead of all of 3D Classic. --- docs/effects-parity.md | 2 +- docs/gaps.md | 4 ++-- docs/target-app-parity.md | 8 ++++---- docs/three-d.md | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 66e1b713e..c8cadd724 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -187,7 +187,7 @@ VectorCraft choices; an original approximation, never compared with another appl The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 h. +angular cut caps, exact visibility, Revolve (Classic), Rotate (Classic), Map Art, Turntable, 3D export) is 25–45 h. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431e..9150803dc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -632,8 +632,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605), with automatic smooth vector shading, default-on circular caps and spike-free continuous rims; Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, - ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, 3D Classic, - Turntable, 3D export (OBJ, USDA, glTF). + ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, Revolve (Classic), + Rotate (Classic), Map Art, Turntable, 3D export (OBJ, USDA, glTF). - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..3e109e455 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -185,7 +185,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | @@ -193,7 +193,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | @@ -455,10 +455,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), more built-in brush libraries (four generated in code are done), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | more built-in symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | diff --git a/docs/three-d.md b/docs/three-d.md index 7cc339dff..f454e1a5f 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -200,8 +200,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is counted on its own), Rotate (which only turns the outline) 65,536. -- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate, - unbuilt items. +- **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), + Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md). From decb3321d1b02e3b5fb73df19a0d5d2cfafb174f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 16:27:43 -0800 Subject: [PATCH 09/38] Docs: the 3D effects summary counts Extrude & Bevel (Classic) --- docs/effects-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/effects-parity.md b/docs/effects-parity.md index c8cadd724..37771d101 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -13,7 +13,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---:|---:|---:|---:| | Illustrator effects (vector and stylize) | 50 | 49 | 98% | 4–8 | | Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | 1–2 (Effect Gallery thumbnails and in-dialog preview) | -| 3D and Materials | 4 (+3 Classic) | 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate) | ~25% | 25–45 | +| 3D and Materials | 4 (+3 Classic) | 4 (+1 Classic; initial: Extrude & Bevel, Revolve, Inflate, Rotate, Extrude & Bevel (Classic)) | ~25% | 25–45 | | Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | ## Illustrator effects From d503688e16f4960cd99f41a33dfb04c42951b9cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 20:10:52 -0800 Subject: [PATCH 10/38] 3D (Classic): effect.lightingPreset refuses Extrude & Bevel (Classic) Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options. effect.lightingPreset now refuses a target whose effect at the index is Extrude & Bevel (Classic) with an error that names it, before any object changes. Before, every preset failed on it with an unknown-parameter error for lightColor. The params text and docs/mcp.md state the rule. --- crates/engine/src/cmd/effectcmd.rs | 9 ++++++- .../cmd/effectcmd/lighting_presets_tests.rs | 24 +++++++++++++++++++ docs/mcp.md | 5 ++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index a823ee161..3b9b66194 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -62,7 +62,7 @@ pub fn specs() -> Vec { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -441,6 +441,13 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { if !effect.is_some_and(|e| effects::is_three_d(&e.id)) { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); } + // Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options. + if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) { + return Err(bad( + C, + format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0), + )); + } } let mut options = p.clone(); let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?; diff --git a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs index 82426c5ea..812ce0966 100644 --- a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs +++ b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs @@ -91,3 +91,27 @@ fn lighting_presets_reject_a_mixed_selection_before_changing_any_effect() { assert_eq!(s.doc().unwrap().history.undo.len(), history); assert!(!s.doc().unwrap().is_dirty()); } + +#[test] +fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection_unchanged() { + let (mut s, first) = setup("threeD.rotate", Value::Null); + let classic = s.execute("shape.rectangle", &json!({"x":250,"y":100,"width":80,"height":80})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.classicExtrude","item":null,"params":{"surface":"diffuse","lightRotation":40}})).unwrap(); + for ids in [vec![classic], vec![first.0, classic]] { + for preset in LIGHTING_PRESETS { + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + let p = json!({"ids":ids,"index":0,"item":null,"preset":preset.id}); + let err = s.execute("effect.lightingPreset", &p).unwrap_err().to_string(); + assert!(err.contains(&format!("object {classic}: Extrude & Bevel (Classic) at index 0 has no lighting presets")), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(!s.doc().unwrap().is_dirty()); + } + } + // Its lights are set with its own parameters instead. + s.execute("effect.setParams", &json!({"ids":[classic],"index":0,"item":null,"params":{"lightRotation":135,"lightHeight":45}})).unwrap(); + let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params; + assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0))); +} diff --git a/docs/mcp.md b/docs/mcp.md index 168c377b8..f2d9f5f13 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2137,8 +2137,9 @@ See [Revolve](revolve.md) for the scope and limitations. `effect.lightingPreset {index: int, preset: "standard" | "diffuse" | "topLeft" | "right", item?, ids?, target?}` replaces the lighting rig and ambient of an existing Revolve, Extrude & Bevel, Inflate or Rotate effect. `item: null` addresses object-level effects; an item index addresses a fill/stroke's -effects. All targets must have a 3D effect at `index`, otherwise nothing changes. The operation -is one undo step and preserves Shading, geometry, source paint and the view. +effects. All targets must have a 3D effect at `index`, otherwise nothing changes. Extrude & Bevel +(Classic) has no lighting presets: a target with it at `index` is an error, nothing changes, and +`effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. The four original rigs use one white front light at 80% intensity. Standard keeps the existing default; Diffuse uses 100% Softness and 35% Ambient Light, Top Left uses Rotation 135 and Height From fd94ebe86c38addfd277f1cc97d3b349f1595f6d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 23:05:09 -0800 Subject: [PATCH 11/38] Plug-ins: importer plug-ins open file types VectorCraft does not read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An importer plug-in (kind "importer", permission import.read) lists 1 to 8 file extensions and turns a file's bytes into objects, which open as a new document whose artboard is their bounds. File › Open lists its extensions, File › Open With (file.openWith) opens a file with a chosen importer, and document.open takes importer and params. A built-in format that reads the file by its content or extension always opens it; among importers listing the same extension, the one whose id sorts first opens it. An importer reads at most 128 MiB, and Place still reads only the built-in formats. Docs: docs/plugins.md, docs/mcp.md, docs/gaps.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio.rs | 7 +- crates/engine/src/cmd/fileio/action_batch.rs | 4 +- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/formats.rs | 84 ++++-- crates/engine/src/cmd/fileio/importer.rs | 115 ++++++++ crates/engine/src/cmd/fileio/load.rs | 15 +- crates/engine/src/cmd/plugin.rs | 22 +- crates/engine/tests/plugin_fuzz.rs | 26 +- crates/engine/tests/plugin_import.rs | 252 ++++++++++++++++++ crates/plugins/src/import.rs | 182 +++++++++++++ crates/plugins/src/lib.rs | 6 +- crates/plugins/src/manifest.rs | 52 +++- crates/plugins/src/permissions.rs | 19 +- crates/plugins/src/wat.rs | 5 + crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/dialogs/plugin.rs | 16 +- .../src/dialogs/tests_plugin_import.rs | 103 +++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 17 ++ crates/ui-egui/src/i18n/cs.tsv | 17 ++ crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 17 ++ crates/ui-egui/src/i18n/el.tsv | 17 ++ crates/ui-egui/src/i18n/es.tsv | 17 ++ crates/ui-egui/src/i18n/fi.tsv | 17 ++ crates/ui-egui/src/i18n/fr.tsv | 17 ++ crates/ui-egui/src/i18n/hi.tsv | 18 +- crates/ui-egui/src/i18n/hu.tsv | 17 ++ crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 17 ++ crates/ui-egui/src/i18n/ja.tsv | 17 ++ crates/ui-egui/src/i18n/ka.tsv | 17 ++ crates/ui-egui/src/i18n/ko.tsv | 17 ++ crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 17 ++ crates/ui-egui/src/i18n/pt-br.tsv | 17 ++ crates/ui-egui/src/i18n/ru.tsv | 17 ++ crates/ui-egui/src/i18n/sv.tsv | 18 +- crates/ui-egui/src/i18n/tr.tsv | 17 ++ crates/ui-egui/src/i18n/uk.tsv | 17 ++ crates/ui-egui/src/i18n/vi.tsv | 17 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 17 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 17 ++ crates/ui-egui/src/io.rs | 2 +- crates/ui-egui/src/io/open.rs | 67 +++++ crates/ui-egui/src/menus/labels.rs | 11 +- crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/picks.rs | 1 + docs/gaps.md | 9 +- docs/mcp.md | 16 +- docs/plugins.md | 70 +++-- docs/target-app-parity.md | 1 + 55 files changed, 1413 insertions(+), 87 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/importer.rs create mode 100644 crates/engine/tests/plugin_import.rs create mode 100644 crates/plugins/src/import.rs create mode 100644 crates/ui-egui/src/dialogs/tests_plugin_import.rs diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 0c888d13c..9903ba4cb 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,8 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no +//! built-in format reads). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -30,6 +32,7 @@ mod encode; pub mod eps; mod export; pub(crate) mod imagemap; +mod importer; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; @@ -128,8 +131,8 @@ use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, - file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, read_extensions, - readable_format, readable_format_for_name, titled_for_export, writable_format, + file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, open_filters, place_filters, + plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index e11b6adcd..5e2178cdc 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -516,12 +516,12 @@ fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result bool { - use super::{FORMATS, OPEN_EXTS, extension}; + use super::{FORMATS, extension, open_extensions}; let ext = extension(path); if ext.is_empty() { return false; } - OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext)) + open_extensions().iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext)) } #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 011d025e3..ec7e303c6 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "File Formats", [], None, - "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", + "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", always, formats ), diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 33ecfa8ab..49f8af812 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -651,11 +651,12 @@ pub fn read_extensions(f: &Format) -> &'static [&'static str] { READ_ALIASES.iter().find(|(id, _)| *id == f.id).map_or(f.extensions, |(_, exts)| *exts) } -/// The readable format of extension `ext` (any case, leading dot allowed): [`format`]'s when it -/// reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). +/// The built-in readable format of extension `ext` (any case, leading dot allowed): [`format`]'s +/// when it reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). Importer plug-ins are +/// not looked at ([`importer_formats`]). pub fn readable_format(ext: &str) -> Option<&'static Format> { let k = ext.trim_start_matches('.').to_ascii_lowercase(); - format(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| format(id))) + builtin(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| builtin(id))) } /// The readable format a file name's extension names ([`readable_format`]). @@ -668,12 +669,38 @@ fn format_filters() -> impl Iterator &'static [&'static str] { + use vectorcraft_plugins::registry; + static MADE: std::sync::Mutex> = std::sync::Mutex::new(None); + let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); + let revision = registry::revision(); + if let Some((r, exts)) = *made + && r == revision + { + return exts; + } + let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec(); + for e in importer_formats().iter().flat_map(|f| f.extensions.iter().copied()) { + if !exts.contains(&e) && readable_format(e).is_none() { + exts.push(e); + } + } + let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) }; + *made = Some((revision, exts)); + exts +} + +/// Open-dialog filters: "All readable files" first, then one per readable format, then one per +/// importer plug-in (under its name), then swatch libraries (which open in the library panel), +/// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked +/// without running them) and plug-ins (installed). pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", OPEN_EXTS)) + std::iter::once(("All readable files", open_extensions())) .chain(format_filters()) + .chain(importer_formats().into_iter().map(|f| (f.label, f.extensions))) .chain(std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS))) .chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS))) @@ -707,26 +734,40 @@ pub fn place_filters() -> impl Iterator Option<&'static Format> { let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase(); - FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str()))).or_else(|| { + builtin(&k).or_else(|| { let added = plugin_formats(); - added.iter().find(|f| f.id.eq_ignore_ascii_case(&k)).or_else(|| added.iter().find(|f| f.extensions.contains(&k.as_str()))).copied() + let by_ext = |f: &&&Format| f.extensions.contains(&k.as_str()); + added + .iter() + .find(|f| f.id.eq_ignore_ascii_case(&k)) + .or_else(|| added.iter().filter(|f| f.write).find(by_ext)) + .or_else(|| added.iter().find(by_ext)) + .copied() }) } -/// The formats the installed exporter plug-ins add ([`vectorcraft_plugins::export`]), by plug-in -/// id: write only, with the plug-in's name as the label and its extensions. Each is made once for -/// each installation of its plug-in, its strings interned, so formats live as long as the catalogues +/// A built-in format by id or extension (lower case, without the dot). +fn builtin(k: &str) -> Option<&'static Format> { + FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k))) +} + +/// The formats the installed exporter and importer plug-ins add ([`vectorcraft_plugins::export`], +/// [`vectorcraft_plugins::import`]), by plug-in id: an exporter's write only and an importer's read +/// only, with the plug-in's name as the label and its extensions. Each is made once for each +/// installation of its plug-in, its strings interned, so formats live as long as the catalogues /// that hold them. pub fn plugin_formats() -> Vec<&'static Format> { - use vectorcraft_plugins::{export, registry}; + use vectorcraft_plugins::{Kind, export, registry}; static MADE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); - export::installed() + registry::list() .iter() + .filter(|p| matches!(p.manifest().kind, Kind::Exporter | Kind::Importer)) .map(|p| { if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) { return *f; @@ -738,8 +779,8 @@ pub fn plugin_formats() -> Vec<&'static Format> { label: registry::intern(&m.name), extensions: Box::leak(extensions.into_boxed_slice()), mime: "application/octet-stream", - read: false, - write: true, + read: m.kind == Kind::Importer, + write: m.kind == Kind::Exporter, raster: false, options: &[], })); @@ -749,6 +790,11 @@ pub fn plugin_formats() -> Vec<&'static Format> { .collect() } +/// The formats the installed importer plug-ins add ([`plugin_formats`] that read), by plug-in id. +pub fn importer_formats() -> Vec<&'static Format> { + plugin_formats().into_iter().filter(|f| f.read).collect() +} + /// The lower-case extension of a file name or path (empty when it has none). pub fn extension(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -837,7 +883,7 @@ pub(super) fn formats(_: &mut Session, _: &Value) -> Result { "formats": all().map(listed).collect::>(), "readable": ids(|f| f.read), "writable": ids(|f| f.write), - "openExtensions": OPEN_EXTS, + "openExtensions": open_extensions(), "unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::>(), })) } diff --git a/crates/engine/src/cmd/fileio/importer.rs b/crates/engine/src/cmd/fileio/importer.rs new file mode 100644 index 000000000..0a341c079 --- /dev/null +++ b/crates/engine/src/cmd/fileio/importer.rs @@ -0,0 +1,115 @@ +//! `document.open` of the files importer plug-ins read ([`vectorcraft_plugins::import`]). +//! +//! A file opens with an importer when `importer` names one (its format id, `plugin.`), which +//! must list the file's extension, or when no built-in format reads the file, by its content or by +//! its extension, and an importer lists its extension (the one whose id sorts first, when several +//! do). A built-in format that can't read a damaged file reports its own error: the file is not +//! handed to an importer instead. Place reads only the built-in formats. +//! +//! The importer is given the file's bytes, at most [`import::MAX_INPUT_BYTES`] of them: a file +//! named by `path` that only an importer can read is read no further than that. Its objects become +//! a new document whose artboard is their bounds, and nothing opens when the importer fails, +//! declines, is cancelled or returns objects that don't pass the checks a filter's output does. + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::Document; +use vectorcraft_geom::Rect; +use vectorcraft_plugins::{Plugin, export, import, objects}; + +use super::super::{Result, bad}; +use super::load::{Loaded, detect, file_name}; +use super::{err, extension, file_stem, format}; +use crate::EngineError; + +const C: &str = "document.open"; + +/// The importer `document.open`'s params `p` name (`importer`), if any; a value that isn't a +/// string is an error. +fn named(p: &Value) -> Result> { + match p.get("importer").filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => v.as_str().map(Some).ok_or_else(|| bad(C, "`importer` must be a plug-in format id (see document.formats)")), + } +} + +/// The importer that opens the file `name` holding `bytes` with `document.open`'s params `p`: the +/// one `importer` names, which must list the file's extension; else, when no built-in format reads +/// the file, the first installed importer that lists its extension; else `None`. +pub(super) fn pick(name: &str, bytes: &[u8], p: &Value) -> Result>> { + let ext = extension(name); + if let Some(id) = named(p)? { + let plugin = import::named(id).map_err(|e| bad(C, e.to_string()))?; + let m = plugin.manifest(); + if !m.extensions.contains(&ext) { + let shown = vectorcraft_plugins::permissions::display_text(&m.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + let exts: Vec = m.extensions.iter().map(|e| format!(".{e}")).collect(); + return Err(bad(C, format!("“{shown}” opens {} files, not `{}`", exts.join(", "), file_name(name)))); + } + return Ok(Some(plugin)); + } + if detect(name, bytes).is_some() { + return Ok(None); + } + Ok(import::for_extension(&ext).into_iter().next()) +} + +/// The file at `path` for `document.open`'s params `p`. A file that only an importer reads (one +/// `importer` names, or an extension no built-in format reads that an importer lists) is read up +/// to [`import::MAX_INPUT_BYTES`]: past that, it is an error, unless the start of the file shows a +/// built-in format and no importer was named, which is read whole as any other file is. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn read_path(path: &str, p: &Value) -> Result> { + let ext = extension(path); + let explicit = named(p)?.is_some(); + if !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) { + return super::read_file(path); + } + let head = super::read_file_head(path, import::MAX_INPUT_BYTES + 1)?; + if head.len() <= import::MAX_INPUT_BYTES { + return Ok(head); + } + if !explicit && detect(path, &head).is_some() { + return super::read_file(path); + } + Err(err(format!("can't open `{}`: {}", file_name(path), import::too_large()))) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn read_path(path: &str, _: &Value) -> Result> { + super::read_file(path) +} + +/// The file `name` holding `bytes` read by importer `plugin` with `document.open`'s params `p` +/// (`params`: the importer's own parameters) into a new document, its artboard the bounds of what +/// the importer returned. +pub(super) fn load(name: &str, bytes: &[u8], plugin: &Plugin, p: &Value) -> Result { + let m = plugin.manifest(); + let format = format(&export::format_id(&m.id)).filter(|f| f.read).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(m.id.clone())))?; + let (ext, stem) = (extension(name), file_stem(name)); + let source = import::Source { extension: &ext, name: &stem }; + let params = p.get("params").cloned().unwrap_or(Value::Null); + let imported = import::run(plugin, bytes, ¶ms, source).map_err(|e| match e { + vectorcraft_plugins::Error::Cancelled => EngineError::Cancelled, + vectorcraft_plugins::Error::Params(why) => bad(C, why), + e => err(format!("can't open `{}`: {e}", file_name(name))), + })?; + let mut doc = Document::new(1.0, 1.0); + let layer = doc.layers.first().map(|l| l.id); + let admit = |fx: &[vectorcraft_doc::Effect], n: &vectorcraft_doc::Node| crate::cmd::plugin::admit_effects_for(C, fx, n); + let ids = + objects::apply_output(&mut doc, &[], imported.objects, layer, &admit).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; + if let (Some(bounds), Some(board)) = (doc.bounds_of(&ids, true), doc.artboards.first_mut()) { + board.rect = at_least_a_point(bounds); + } + doc.title = file_name(name); + Ok(Loaded { doc, format, warnings: imported.warnings, restored: false, converted: false, preview_only: false, editing_data: false }) +} + +/// `r` at least 1 pt wide and high, about its center: the artboard of a single line or point. +fn at_least_a_point(r: Rect) -> Rect { + let grow = |lo: f64, hi: f64| if hi - lo >= 1.0 { (lo, hi) } else { ((lo + hi) / 2.0 - 0.5, (lo + hi) / 2.0 + 0.5) }; + let ((x0, x1), (y0, y1)) = (grow(r.x0, r.x1), grow(r.y0, r.y1)); + Rect::new(x0, y0, x1, y1) +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index a1ec671aa..f32a456b1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -555,10 +555,15 @@ impl ReadFile { /// thread): read and decode the file, read its linked files and lay out its type → what /// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update -/// Links set to Automatically; see [`crate::cmd::links::resolve`]). +/// Links set to Automatically; see [`crate::cmd::links::resolve`]). A file an importer plug-in +/// reads (`importer` in `p`, or an extension only an importer lists) is read by it +/// ([`super::importer`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let mut loaded = load_with(name, bytes, &opts)?; + let mut loaded = match super::importer::pick(name, bytes, p)? { + Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, + None => load_with(name, bytes, &opts)?, + }; let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) @@ -663,7 +668,11 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result> { } pub(super) fn open(s: &mut Session, p: &Value) -> Result { - let src = source(p, "document.open")?; + // A file only an importer plug-in reads is read up to the importer's input limit. + let src = match str_param(p, "path") { + Some(path) => Source { name: path, bytes: super::importer::read_path(path, p)?, path: Some(path) }, + None => source(p, "document.open")?, + }; // A relative path is the file in the working directory: its folder is where relative links // are found (a bare file name has no folder of its own). let path = src.path.map(super::absolute_path); diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 565eded7f..fe6a562bf 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -7,7 +7,8 @@ //! plug-in that fails leaves the document untouched), and read and write the data they keep with //! the document ([`vectorcraft_doc::plugin_data`], theirs alone); live-effect plug-ins are applied //! like any effect (`effect.apply {effect: "plugin."}`) and drawn by `vectorcraft-effects`; -//! exporters add a file format that `document.export` writes (`fileio::plugin_formats`). +//! exporters add a file format that `document.export` writes, and importers one that +//! `document.open` reads (`fileio::plugin_formats`). //! //! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing, //! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`), @@ -49,7 +50,7 @@ pub fn describe(p: &Plugin) -> Value { }); match m.kind { Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)), - Kind::Exporter => { + Kind::Exporter | Kind::Importer => { v["format"] = json!(export::format_id(&m.id)); v["extensions"] = json!(m.extensions); } @@ -128,6 +129,12 @@ impl FilterRun { format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id)), )); } + Kind::Importer => { + return Err(bad( + C, + format!("`{}` is an importer: open a file with document.open {{\"importer\": \"{}\"}}", m.id, export::format_id(&m.id)), + )); + } } // Bad parameters fail before anything runs. let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); @@ -247,8 +254,13 @@ fn run(s: &mut Session, p: &Value) -> Result { /// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the /// host read one. fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, String> { + admit_effects_for("plugin.run", fx, n) +} + +/// [`admit_effects`] for command `cmd`, which bad parameters name: `plugin.run`, or `document.open` +/// for the objects an importer plug-in returns. +pub(crate) fn admit_effects_for(cmd: &str, fx: &[Effect], n: &Node) -> std::result::Result, String> { use vectorcraft_render::effects; - const C: &str = "plugin.run"; let mut out = Vec::with_capacity(fx.len()); for (j, e) in fx.iter().enumerate() { if n.appearance.effects.contains(e) { @@ -261,7 +273,7 @@ fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, St Some(plugin) => { plugin.manifest().resolve_params(¶ms).map_err(|why| refuse(why.to_string()))?; } - None => super::params::check_effect_params(C, &e.id, ¶ms).map_err(|why| refuse(why.to_string()))?, + None => super::params::check_effect_params(cmd, &e.id, ¶ms).map_err(|why| refuse(why.to_string()))?, } if super::effectcmd::texture_load_effect(&e.id) && let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE) @@ -493,7 +505,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1f29627e0..2a4a5f991 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -242,6 +242,24 @@ proptest! { renders(&s, &out)?; } + /// An importer's hostile output (`document.open`) either opens a document that renders and + /// exports, or is an error that opens nothing. + #[test] + fn hostile_importer_output_never_crashes( + objects in prop::collection::vec(arb_object(), 0..5), + junk in prop::option::weighted(0.1, arb_junk()), + ) { + let mut s = Session::new(); + let out = output(&objects, &[], junk); + install(&mut s, &wat::parse_str(tpl::returning(&tpl::importer("fuzz.importer", r#"["fzi"]"#, "{}"), &out)).unwrap()).unwrap(); + let r = catch_quiet(|| s.execute("document.open", &json!({"name": "x.fzi", "dataBase64": ""})).map(|_| ())); + match r { + Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))), + Ok(Err(_)) => prop_assert!(s.documents().is_empty(), "a failed open opened a document: {}", out), + Ok(Ok(())) => renders(&s, &out)?, + } + } + #[test] fn mutated_and_garbage_modules_never_crash( flips in prop::collection::vec((any::(), any::()), 0..8), @@ -276,10 +294,10 @@ proptest! { /// character from the name. #[test] fn hostile_manifest_permissions_never_crash( - kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + kind in prop::sample::select(vec!["filter", "effect", "exporter", "importer"]), names in prop::collection::vec( prop_oneof![ - prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]), + prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress", "ui.panel", ""]), Just("doc.read\u{202E}"), Just("\u{0007}"), ], @@ -288,10 +306,10 @@ proptest! { permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), api in prop_oneof![3 => Just(json!(1)), 1 => arb_junk()], name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"], - grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]))], + grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read"]))], ) { let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); - if kind == "exporter" { + if matches!(kind, "exporter" | "importer") { m["extensions"] = json!(["fz"]); } m["permissions"] = match permissions { diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs new file mode 100644 index 000000000..f56684172 --- /dev/null +++ b/crates/engine/tests/plugin_import.rs @@ -0,0 +1,252 @@ +//! Importer plug-ins (`"kind": "importer"`, docs/plugins.md › Importers) through the engine: the +//! formats they add to `document.formats` and `document.open`, which files they are given, the +//! document their objects become, and what happens when they fail, decline or are cancelled. +//! +//! A test binary of its own: installed plug-ins are process-wide, and the unit tests that check the +//! exact list of formats would see these ones. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_engine::{EngineError, Session}; +use vectorcraft_plugins::wat::{self as tpl, Given}; + +/// An importer's output: one red rectangle from (10, 20) to (110, 70) and a warning. +const RECT: &str = r#"{"objects":[{"type":"path","name":"Box","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]},"fills":[{"type":"solid","color":{"model":"rgb","r":1,"g":0,"b":0}}]}],"warnings":["2 layers merged"]}"#; + +/// An importer's output: a horizontal line from (0, 5) to (50, 5). +const LINE: &str = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,5]},{"p":[50,5]}]}]}}]}"#; + +/// Installs the plug-in written in WAT `src` → its `plugin.list` entry. +fn install(s: &mut Session, src: &str) -> Value { + let wasm = wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}")); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap() +} + +/// An importer `id` reading `extensions` (a JSON list) whose objects are the file it is given. +fn echo(s: &mut Session, id: &str, extensions: &str) -> Value { + install(s, &tpl::module(&tpl::importer(id, extensions, "{}"), tpl::ECHO, "", 1)) +} + +/// `document.open` of a file `name` holding `bytes`, with more params `extra`. +fn open(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> Result { + let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + s.execute("document.open", &p) +} + +/// The active document's first artboard, as `(x0, y0, x1, y1)`. +fn artboard(s: &Session) -> (f64, f64, f64, f64) { + let r = s.doc().unwrap().doc.artboards[0].rect; + (r.x0, r.y0, r.x1, r.y1) +} + +/// How many objects the active document's first layer holds. +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers[0].children().unwrap().len() +} + +/// A fresh folder for files this test writes. +fn tmp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vc-plugin-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn path_of(p: &std::path::Path) -> String { + p.to_string_lossy().into_owned() +} + +#[test] +fn an_importer_adds_a_read_only_format_while_it_is_installed() { + let mut s = Session::new(); + let entry = echo(&mut s, "test.list", r#"["tsl","tsl2"]"#); + assert_eq!((&entry["kind"], &entry["format"], &entry["extensions"]), (&json!("importer"), &json!("plugin.test.list"), &json!(["tsl", "tsl2"]))); + assert_eq!(entry["permissions"], json!(["import.read"])); + let formats = s.execute("document.formats", &json!({})).unwrap(); + let listed = formats["formats"].as_array().unwrap().iter().find(|f| f["id"] == "plugin.test.list").expect("listed"); + assert_eq!((&listed["label"], &listed["extensions"]), (&json!("Test test.list"), &json!(["tsl", "tsl2"]))); + assert_eq!((&listed["read"], &listed["write"], &listed["plugin"]), (&json!(true), &json!(false), &json!("test.list"))); + assert!(formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(!formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(formats["openExtensions"].as_array().unwrap().contains(&json!("tsl2"))); + assert!(fileio::open_extensions().contains(&"tsl")); + assert!(!fileio::export_extensions().contains(&"tsl")); + assert_eq!(fileio::format(".TSL2").map(|f| f.id), Some("plugin.test.list"), "by any of its extensions, in any case"); + + s.execute("plugin.remove", &json!({"id": "test.list"})).unwrap(); + let formats = s.execute("document.formats", &json!({})).unwrap(); + assert!(!formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(!formats["openExtensions"].as_array().unwrap().contains(&json!("tsl"))); + assert!(fileio::format("tsl").is_none()); + assert!(open(&mut s, "a.tsl", RECT.as_bytes(), json!({})).is_err()); + assert!(s.documents().is_empty()); +} + +/// A file of an extension only the importer lists opens as a new document of editable art, its +/// artboard the art's bounds, with the importer's warnings; Save then asks for a name. +#[test] +fn a_file_an_importer_reads_opens_as_editable_art() { + let dir = tmp_dir("open"); + let mut s = Session::new(); + echo(&mut s, "test.open", r#"["tso"]"#); + let r = open(&mut s, "Shapes.tso", RECT.as_bytes(), json!({})).unwrap(); + assert_eq!((&r["format"], &r["warnings"], &r["restored"]), (&json!("plugin.test.open"), &json!(["2 layers merged"]), &json!(false))); + assert_eq!(s.documents().len(), 1); + assert_eq!(objects(&s), 1); + assert_eq!(artboard(&s), (10.0, 20.0, 110.0, 70.0)); + // Editable: the rectangle moves like any path. + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + + // A line's artboard is at least 1 pt high. + open(&mut s, "Line.tso", LINE.as_bytes(), json!({})).unwrap(); + assert_eq!(artboard(&s), (0.0, 4.5, 50.0, 5.5)); + + // From a path: the document keeps no path, so Save asks for a name. + let path = dir.join("Shapes.tso"); + std::fs::write(&path, RECT).unwrap(); + let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap(); + assert_eq!(r["format"], "plugin.test.open"); + assert_eq!(s.doc().unwrap().path, None); + assert!(s.doc().unwrap().doc.title.contains("Shapes"), "{}", s.doc().unwrap().doc.title); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A built-in format keeps its files: an importer that lists `svg` is given an `.svg` only when +/// `importer` names it, and a file with a built-in format's content opens with the built-in reader +/// whatever its extension. +#[test] +fn built_in_formats_keep_their_files() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.svgtoo", r#"["tsv","svg"]"#, "{}"), RECT)); + let svg = br#""#; + assert_eq!(open(&mut s, "a.svg", svg, json!({})).unwrap()["format"], "svg"); + // A damaged SVG reports the SVG reader's error; the importer isn't tried instead. + match open(&mut s, "b.svg", b" assert_eq!(r["format"], "svg"), + Err(e) => assert!(!e.to_string().contains("importer"), "{e}"), + } + // Named, the importer opens an .svg. + let r = open(&mut s, "c.svg", svg, json!({"importer": "plugin.test.svgtoo"})).unwrap(); + assert_eq!(r["format"], "plugin.test.svgtoo"); + // An SVG named as the importer's file is still an SVG. + assert_eq!(open(&mut s, "d.tsv", svg, json!({})).unwrap()["format"], "svg"); + assert_eq!(fileio::format("svg").map(|f| f.id), Some("svg")); + assert_eq!(fileio::open_extensions().iter().filter(|e| **e == "svg").count(), 1); +} + +/// Several importers list one extension: the one whose id sorts first opens the file, and +/// `importer` picks another (by its format id or its id). +#[test] +fn the_first_importer_by_id_opens_a_shared_extension() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.shared.b", r#"["tsh"]"#, "{}"), LINE)); + install(&mut s, &tpl::returning(&tpl::importer("test.shared.a", r#"["tsh"]"#, "{}"), RECT)); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({})).unwrap()["format"], "plugin.test.shared.a"); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "plugin.test.shared.b"})).unwrap()["format"], "plugin.test.shared.b"); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "test.shared.b"})).unwrap()["format"], "plugin.test.shared.b"); +} + +#[test] +fn importer_names_that_do_not_fit_are_errors() { + let mut s = Session::new(); + echo(&mut s, "test.names", r#"["tsn"]"#); + install(&mut s, &tpl::returning(&tpl::manifest("test.names.filter", "filter", "{}"), r#"{"objects":[]}"#)); + let cases = [ + (json!({"importer": "plugin.test.names"}), "x.png", "opens .tsn files, not `x.png`"), + (json!({"importer": "plugin.test.names.filter"}), "x.tsn", "is a filter, not an importer"), + (json!({"importer": "plugin.test.nothere"}), "x.tsn", "test.nothere"), + (json!({"importer": 3}), "x.tsn", "`importer` must be a plug-in format id"), + ]; + for (extra, name, why) in cases { + let e = open(&mut s, name, RECT.as_bytes(), extra.clone()).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{extra}: {e}"); + assert!(e.to_string().contains(why), "{extra}: {e}"); + } + assert!(s.documents().is_empty()); + // Without an importer, a file nothing reads stays unsupported. + assert!(open(&mut s, "x.tsunknown", RECT.as_bytes(), json!({})).is_err()); + // An importer is not a filter. + s.execute("file.new", &json!({})).unwrap(); + let e = s.execute("plugin.run", &json!({"id": "test.names"})).unwrap_err(); + assert!(e.to_string().contains("document.open"), "{e}"); +} + +/// The importer is given its parameters, checked against its manifest, and `_context` with the +/// file's extension and name. +#[test] +fn the_importer_is_given_its_parameters_and_the_file_s_name() { + let mut s = Session::new(); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + let needles = [r#""mode":"import""#, r#""extension":"tsc""#, r#""name":"Shapes""#, r#""density":10.0"#]; + for needle in needles { + // RECT when the parameters hold the needle; the file, which isn't JSON, otherwise. + install(&mut s, &tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT)); + let r = open(&mut s, "Shapes.tsc", b"not json", json!({"params": {"density": 12}})); + assert_eq!(r.map(|r| r["format"].clone()).map_err(|e| e.to_string()), Ok(json!("plugin.test.params")), "{needle}"); + } + let before = s.documents().len(); + let e = open(&mut s, "Shapes.tsc", b"", json!({"params": {"density": "lots"}})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{e}"); + assert_eq!(s.documents().len(), before); +} + +#[test] +fn failures_open_nothing() { + let mut s = Session::new(); + let far = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[9000000,0]}]}]}}]}"#; + let bad_warnings = LINE.replace("]}]}}]}", r#"]}]}}],"warnings":"no"}"#); + let cases = [ + ("test.fail.declines", r#"{"error":"Not a drawing."}"#, "Not a drawing."), + ("test.fail.notjson", "not json", "JSON"), + ("test.fail.empty", r#"{"objects":[]}"#, "the importer returned no objects"), + ("test.fail.key", r#"{"objects":[],"layers":[]}"#, "an importer returns `objects` and `warnings`, not `layers`"), + ("test.fail.id", r#"{"objects":[{"id":3}]}"#, "is not the id of an input object"), + ("test.fail.far", far, "4000000"), + ("test.fail.warnings", bad_warnings.as_str(), "`warnings` must be a list of strings"), + ]; + for (id, output, why) in cases { + install(&mut s, &tpl::returning(&tpl::importer(id, r#"["tsf"]"#, "{}"), output)); + let e = open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err(); + assert!(e.to_string().contains(why), "{id}: {e}"); + assert!(e.to_string().contains("x.tsf"), "{id}: names the file: {e}"); + } + install(&mut s, &tpl::module(&tpl::importer("test.fail.traps", r#"["tsf"]"#, "{}"), "(unreachable)", "", 1)); + assert!(open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.traps"})).is_err()); + assert!(s.documents().is_empty(), "nothing opened"); +} + +/// Cancel stops an importer between two slices of its budget, and nothing opens: here one that +/// never returns, run with a token cancelled beforehand. +#[test] +fn a_cancelled_open_stops_the_importer() { + let mut s = Session::new(); + install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tss", b"?", json!({}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert!(s.documents().is_empty()); +} + +/// A file past the importer's input limit is not read past it, and doesn't open. +#[test] +fn a_file_past_the_input_limit_is_refused() { + let dir = tmp_dir("large"); + let mut s = Session::new(); + echo(&mut s, "test.large", r#"["tsb"]"#); + let path = dir.join("big.tsb"); + std::fs::File::create(&path).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap(); + let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert!(e.to_string().contains("big.tsb"), "{e}"); + assert!(s.documents().is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/plugins/src/import.rs b/crates/plugins/src/import.rs new file mode 100644 index 000000000..5a7be6a85 --- /dev/null +++ b/crates/plugins/src/import.rs @@ -0,0 +1,182 @@ +//! Importer plug-ins: a plug-in of kind `importer` reads files in a format of its own, such as a +//! pen plotter's or a cutting machine's, into a new document. +//! +//! File › Open opens a file with an importer only when no built-in format reads it: the built-in +//! formats are found by the file's content first and then by its extension, and an importer is +//! picked by an extension it lists. When several importers list the extension, the one whose id +//! sorts first opens it ([`for_extension`]). File › Open With and `document.open {importer}` pick an +//! importer by its format id (`plugin.`), also for an extension a built-in format reads. +//! +//! The host hands the importer the file's bytes as they are, at most [`MAX_INPUT_BYTES`] of them, +//! and reads back the objects of the new document in the same form filters return them +//! ([`crate::objects`]): `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline. +//! An import is one run the user asked for, so it gets an export's budget ([`crate::export::FUEL`] +//! and [`crate::export::WALL_TIME_MS`]), and Cancel stops it between two slices of the budget +//! ([`Error::Cancelled`]). + +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::objects::{OutObject, declined, printable}; +use crate::{Error, Kind, Limits, Plugin, Result, registry}; + +/// The largest file an importer is given, in bytes: 128 MiB, a quarter of a plug-in's memory +/// limit, so the file and the objects made from it fit beside each other. +pub const MAX_INPUT_BYTES: usize = 128 << 20; + +/// The keys an importer's output may have. +const OUTPUT_KEYS: &[&str] = &["objects", "warnings", "error"]; + +/// The installed importers, by plug-in id. +pub fn installed() -> Vec> { + registry::list().into_iter().filter(|p| p.manifest().kind == Kind::Importer).collect() +} + +/// The installed importer of format id `format_id` (`plugin.`). +pub fn by_format(format_id: &str) -> Option> { + registry::get(crate::export::plugin_id(format_id)?).filter(|p| p.manifest().kind == Kind::Importer) +} + +/// The installed importer `importer` names: its format id (`plugin.`) or its plug-in id. A +/// plug-in of another kind, and one that isn't installed, are errors that say so. +pub fn named(importer: &str) -> Result> { + let id = crate::export::plugin_id(importer).unwrap_or(importer); + let plugin = registry::get(id).ok_or_else(|| Error::NotFound(id.into()))?; + match plugin.manifest().kind { + Kind::Importer => Ok(plugin), + _ => Err(not_importer(plugin.manifest())), + } +} + +/// The error of plug-in `m`, which is not an importer, asked to import. +fn not_importer(m: &crate::Manifest) -> Error { + Error::Params(format!("`{}` is a {}, not an importer", m.id, m.kind.name())) +} + +/// The installed importers that list extension `ext` (any case, leading dot allowed), by plug-in +/// id: the first is the one File › Open uses. +pub fn for_extension(ext: &str) -> Vec> { + let ext = ext.trim_start_matches('.').to_ascii_lowercase(); + installed().into_iter().filter(|p| p.manifest().extensions.contains(&ext)).collect() +} + +/// What an importer returned. +#[derive(Clone, Debug, PartialEq)] +pub struct Imported { + /// The objects of the new document, in paint order (bottom first), checked as a filter's are. + pub objects: Vec, + /// What the import left out or changed, for the user (at most + /// [`crate::export::MAX_WARNINGS`]). + pub warnings: Vec, +} + +/// The file an import reads, as the importer is told in `_context`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Source<'a> { + /// Its extension, without the dot: one the importer lists. + pub extension: &'a str, + /// Its name without the folder and extension. + pub name: &'a str, +} + +/// The error of a file larger than [`MAX_INPUT_BYTES`], which no importer is given. +pub fn too_large() -> Error { + Error::Io(format!("the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads")) +} + +/// The limits of an import by `plugin`: its own, with the budgets of an export. +pub fn limits(plugin: &Plugin) -> Limits { + crate::export::limits(plugin) +} + +/// Runs importer `plugin` on `file`, the bytes of the file being opened, with `params` (checked +/// against its manifest: missing ones take their defaults) for the file `source`. +pub fn run(plugin: &Plugin, file: &[u8], params: &Value, source: Source) -> Result { + let m = plugin.manifest(); + if m.kind != Kind::Importer { + return Err(not_importer(m)); + } + if file.len() > MAX_INPUT_BYTES { + return Err(too_large()); + } + let mut params = Value::Object(m.resolve_params(params)?); + params["_context"] = json!({"mode": "import", "extension": source.extension, "name": source.name}); + let params = serde_json::to_vec(¶ms).map_err(|e| Error::Params(e.to_string()))?; + decode(&plugin.run_limited(file, ¶ms, &limits(plugin))?) +} + +/// An importer's output: `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline +/// with a message for the user. The objects are new ones (no `id`), checked as a filter's are; an +/// output without any, or with keys of its own, is refused. +pub fn decode(bytes: &[u8]) -> Result { + let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; + if let Some(e) = declined(&v) { + return Err(e); + } + if let Some(key) = v.as_object().and_then(|o| o.keys().find(|k| !OUTPUT_KEYS.contains(&k.as_str()))) { + let key = printable(&json!(key), 64); + return Err(Error::Output(format!("an importer returns `objects` and `warnings`, not `{key}`"))); + } + let objects = crate::objects::decode(bytes)?; + if objects.is_empty() { + return Err(Error::Output("the importer returned no objects".into())); + } + let warnings = match v.get("warnings") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(w)) => w.iter().take(crate::export::MAX_WARNINGS).map(|w| printable(w, crate::export::MAX_WARNING_CHARS)).collect(), + Some(_) => return Err(Error::Output("`warnings` must be a list of strings".into())), + }; + Ok(Imported { objects, warnings }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SQUARE: &str = r#"{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[10,0]},{"p":[10,10]}]}]}}"#; + + #[test] + fn output_is_the_objects_and_the_warnings_or_a_decline() { + let out = decode(format!(r#"{{"objects":[{SQUARE}],"warnings":["a",2]}}"#).as_bytes()).unwrap(); + assert_eq!((out.objects.len(), out.warnings), (1, vec!["a".to_string(), "2".to_string()])); + assert!(out.objects.iter().all(|o| o.id.is_none() && o.path.is_some())); + assert_eq!(decode(format!(r#"{{"objects":[{SQUARE}]}}"#).as_bytes()).unwrap().warnings, Vec::::new()); + assert_eq!(decode(br#"{"error": "Not a\nplot file."}"#), Err(Error::Failed("Not aplot file.".into()))); + } + + /// Nothing an importer returns reaches a document unchecked: malformed JSON, no objects, keys + /// the host doesn't read, bad warnings and bad objects (coordinates out of range, unknown paints) + /// are all errors. + #[test] + fn bad_output_is_refused() { + let far = r#"{"objects":[{"path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#; + for (bad, says) in [ + (&b"[]"[..], "objects"), + (b"{}", "objects"), + (b"not json", "not JSON"), + (br#"{"objects":[]}"#, "no objects"), + (br#"{"objects":{}}"#, "objects"), + (br#"{"objects":[1]}"#, "JSON object"), + (far.as_bytes(), "out of range"), + (br#"{"objects":[{"path":{"subpaths":[]},"fills":[{"type":"hatch"}]}]}"#, "objects[0]"), + (br#"{"objects":[],"artboard":[0,0,1,1]}"#, "not `artboard`"), + ] { + let e = decode(bad).unwrap_err().to_string(); + assert!(e.contains(says), "{}: {e}", String::from_utf8_lossy(bad)); + } + let warns = format!(r#"{{"objects":[{SQUARE}],"warnings":"x"}}"#); + assert!(decode(warns.as_bytes()).unwrap_err().to_string().contains("`warnings`")); + } + + #[test] + fn warnings_are_capped_in_number_and_length() { + let many: Vec = (0..crate::export::MAX_WARNINGS + 5).map(|i| format!("w{i}")).collect(); + let square: Value = serde_json::from_str(SQUARE).unwrap(); + let out = decode(json!({"objects": [square], "warnings": many}).to_string().as_bytes()).unwrap(); + assert_eq!(out.warnings.len(), crate::export::MAX_WARNINGS); + let long = "x".repeat(crate::export::MAX_WARNING_CHARS + 1); + let out = decode(json!({"objects": [square], "warnings": [long]}).to_string().as_bytes()).unwrap(); + assert_eq!(out.warnings.first().map(|w| w.chars().count()), Some(crate::export::MAX_WARNING_CHARS)); + } +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 9e6f9e934..47037c4db 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -19,7 +19,8 @@ //! the objects to keep, change or add, applied to the document as one edit //! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each //! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole -//! document in a file format of its own. The process-wide [`registry`] holds the installed +//! document in a file format of its own; an **importer** ([`import`]) reads a file in a format of +//! its own into a new document. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show //! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is //! the set of [`permissions`] its manifest lists, which the user approves when it is installed. @@ -28,6 +29,7 @@ pub mod dialog; pub mod effect; pub mod export; +pub mod import; pub mod manifest; pub mod objects; pub mod permissions; @@ -40,7 +42,7 @@ pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect -/// (`plugin.`, [`effect`]) or its file format ([`export`]). +/// (`plugin.`, [`effect`]) or its file format ([`export`], [`import`]). pub const PREFIX: &str = "plugin."; /// A plug-in error. Every way a module can misbehave ends here, never in a panic. diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 75a76cdbb..de4013869 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -18,7 +18,7 @@ pub const MAX_UNGROUPED: usize = 64; pub const MAX_TEXT: usize = 4_096; /// Most numbers a `numberList` parameter may hold. pub const MAX_LIST: usize = 256; -/// Most file extensions an exporter may declare. +/// Most file extensions an exporter or an importer may declare. pub const MAX_EXTENSIONS: usize = 8; /// What a plug-in is. @@ -34,6 +34,9 @@ pub enum Kind { /// An exporter: writes the document in a file format of its own, whose extensions the manifest /// lists (File › Export › Export As, `document.export`; see [`crate::export`]). Exporter, + /// An importer: reads a file in a format of its own, whose extensions the manifest lists, into + /// a new document (File › Open, `document.open`; see [`crate::import`]). + Importer, } impl Kind { @@ -42,6 +45,7 @@ impl Kind { Kind::Filter => "filter", Kind::Effect => "effect", Kind::Exporter => "exporter", + Kind::Importer => "importer", } } } @@ -253,8 +257,8 @@ pub struct Manifest { /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] pub params: Vec<(String, Param)>, - /// An exporter's file extensions, lower case and without the dot; the first is the one its - /// files get. Only exporters declare them. + /// An exporter's or an importer's file extensions, lower case and without the dot; the first is + /// the one an exporter's files get. Only exporters and importers declare them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extensions: Vec, /// How much a run may compute: only a filter may be [`Budget::Heavy`]. @@ -319,6 +323,9 @@ impl Manifest { (Budget::Heavy, Kind::Exporter) => { return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into())); } + (Budget::Heavy, Kind::Importer) => { + return Err(Error::Manifest("an importer always gets an import's budget: leave `budget` out".into())); + } } for (i, (k, p)) in self.params.iter().enumerate() { if !valid_key(k) { @@ -333,17 +340,17 @@ impl Manifest { Ok(()) } - /// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case - /// letters and digits; other kinds list none. + /// An exporter or an importer lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 + /// lower-case letters and digits; other kinds list none. fn validate_extensions(&self) -> Result<()> { let exts = &self.extensions; + let count_ok = !exts.is_empty() && exts.len() <= MAX_EXTENSIONS; match self.kind { - Kind::Exporter => {} + Kind::Exporter if !count_ok => return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`"))), + Kind::Importer if !count_ok => return Err(Error::Manifest(format!("an importer declares 1 to {MAX_EXTENSIONS} `extensions`"))), + Kind::Exporter | Kind::Importer => {} _ if exts.is_empty() => return Ok(()), - _ => return Err(Error::Manifest("only an exporter declares `extensions`".into())), - } - if exts.is_empty() || exts.len() > MAX_EXTENSIONS { - return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`"))); + _ => return Err(Error::Manifest("only an exporter or an importer declares `extensions`".into())), } for (i, e) in exts.iter().enumerate() { if !(1..=16).contains(&e.len()) || !e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) { @@ -608,6 +615,31 @@ mod tests { } } + /// An importer lists the extensions it reads by the exporter's rules, and gets an import's + /// budget and the `import.read` permission. + #[test] + fn an_importer_lists_its_extensions() { + let m = Manifest::parse(br#"{"id":"org.example.plotter","name":"Plotter","kind":"importer","extensions":["plt","hpgl"]}"#).unwrap(); + assert_eq!((m.kind, m.extensions.as_slice()), (Kind::Importer, &["plt".to_string(), "hpgl".to_string()][..])); + assert_eq!(serde_json::to_value(&m).unwrap()["kind"], json!("importer")); + assert_eq!(m.permissions(), [Permission::ImportRead]); + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"importer"}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":[]}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["a","b","c","d","e","f","g","h","i"]}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["Plt"]}"#, "lower-case"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":[".plt"]}"#, "without the dot"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["abcdefghijklmnopq"]}"#, "1-16"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt","plt"]}"#, "listed twice"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"budget":"heavy"}"#, "an import's budget"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"permissions":["doc.read"]}"#, "doesn't apply"), + (r#"{"id":"a","name":"x","kind":"filter","extensions":["plt"]}"#, "only an exporter or an importer"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + } + /// A filter may ask for a heavy budget; a live effect runs while the document is drawn and an /// exporter has an export's budget, so they can't. Light, the default, isn't written back. #[test] diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 1d6b0664e..540a091a3 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -47,10 +47,14 @@ pub enum Permission { /// Write the document as a file in its own format (an exporter). #[serde(rename = "export.write")] ExportWrite, + /// Read a file the user opens and make a new document from it (an importer). + #[serde(rename = "import.read")] + ImportRead, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 5] = [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite]; +pub const ALL: [Permission; 6] = + [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::ImportRead]; impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. @@ -61,6 +65,7 @@ impl Permission { Permission::State => "state", Permission::EffectRender => "effect.render", Permission::ExportWrite => "export.write", + Permission::ImportRead => "import.read", } } @@ -77,6 +82,7 @@ impl Permission { Permission::State => "Keep data of its own with the document", Permission::EffectRender => "Change how the objects its effect is applied to are drawn", Permission::ExportWrite => "Write the document as a file in its own format", + Permission::ImportRead => "Read files you open in its format and make documents from them", } } @@ -87,16 +93,17 @@ impl Permission { Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], Kind::Effect => &[Permission::DocRead, Permission::EffectRender], Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite], + Kind::Importer => &[Permission::ImportRead], } } /// The permissions a plug-in of `kind` can't work without, which its list must hold: a live - /// effect and an exporter are always handed objects, and only draw or write. A filter may - /// leave any of its permissions out. + /// effect and an exporter are always handed objects, and only draw or write; an importer is + /// always handed the file being opened. A filter may leave any of its permissions out. fn required(kind: Kind) -> &'static [Permission] { match kind { Kind::Filter => &[], - Kind::Effect | Kind::Exporter => Permission::implied(kind), + Kind::Effect | Kind::Exporter | Kind::Importer => Permission::implied(kind), } } } @@ -218,7 +225,11 @@ mod tests { assert_eq!(validate(Kind::Filter, &[]).unwrap(), []); assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Importer, &list(&["import.read"])).unwrap(), [Permission::ImportRead]); for (kind, names, says) in [ + (Kind::Importer, &["import.read", "doc.read"][..], "`doc.read` doesn't apply to a plug-in of kind `importer`"), + (Kind::Importer, &[], "needs the permission `import.read`"), + (Kind::Exporter, &["export.write", "doc.read", "import.read"], "`import.read` doesn't apply to a plug-in of kind `exporter`"), (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), (Kind::Filter, &["progress"], "`progress` is reserved"), (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 0201e1a50..13570d36f 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -117,6 +117,11 @@ pub fn exporter(id: &str, extensions: &str, params: &str) -> String { format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#) } +/// The manifest of a test importer `id` reading `extensions` (a JSON list) with `params`. +pub fn importer(id: &str, extensions: &str, params: &str) -> String { + format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"importer","extensions":{extensions},"params":{params}}}"#) +} + /// What [`echo_exporter`] writes as its file. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Echo { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ed9f909b3..c98a23887 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -803,6 +803,8 @@ mod tests_plugin_export; #[cfg(test)] mod tests_plugin_fields; #[cfg(test)] +mod tests_plugin_import; +#[cfg(test)] mod tests_raster_formats; #[cfg(test)] mod tests_svg_options_range; diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eef4787ee..8096f1e58 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -8,7 +8,8 @@ //! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes: //! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its -//! Export button writes the file. +//! Export button writes the file. Importers open the files File › Open finds no built-in format +//! for, and File › Open With lists them, to open a file with one of them by name. use std::sync::Arc; @@ -56,6 +57,18 @@ pub fn object_menu() -> Vec { items } +/// File › Open With's header while no importer plug-in is installed. +pub const NO_IMPORTERS: &str = "No importer plug-ins installed"; + +/// File › Open With: the installed importers (`file.openWith {importer}`), each by its name. +pub fn open_with_menu() -> Vec { + let items: Vec = vectorcraft_plugins::import::installed() + .iter() + .map(|p| Item::Cmd(label(&p.manifest().name, true), "file.openWith", json!({ "importer": vectorcraft_plugins::export::format_id(p.id()) }))) + .collect(); + if items.is_empty() { vec![Item::Header(NO_IMPORTERS)] } else { items } +} + /// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without /// options apply at once, as built-in ones do. pub fn effect_menu() -> Option { @@ -79,6 +92,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Kind::Filter => {} Kind::Effect => return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)), Kind::Exporter => return Err(format!("{} is an exporter (File › Export › Export As…)", m.name)), + Kind::Importer => return Err(format!("{} is an importer (File › Open With)", m.name)), } let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); if m.params.is_empty() || given.is_some() { diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs new file mode 100644 index 000000000..7d2e7b9a1 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -0,0 +1,103 @@ +//! Importer plug-ins in File › Open With and File › Open: listed by name under Open With, which +//! opens a file with the importer also when a built-in format reads its extension, and a file only +//! an importer reads is not read past the importer's input limit. Each test installs importers of +//! its own and removes them: plug-ins are process-wide, and an importer left installed would show +//! in other tests' Open dialog. + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +use super::*; +use crate::Services; +use crate::menus::Item; + +/// An importer's output: one rectangle from (10, 20) to (110, 70). +const RECT: &str = + r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#; + +/// An app that reads files from disk, refusing to read one past the importers' input limit. +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| match std::fs::metadata(p) { + Ok(m) if m.len() > vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 => Err("read past the limit".into()), + _ => std::fs::read(p).map_err(|e| e.to_string()), + })); + app +} + +fn install(app: &mut VectorcraftApp, src: &str) { + let wasm = wat::parse_str(src).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap(); +} + +/// A fresh folder for files this test writes. +fn tmp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-ui-plugin-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The active document's first artboard, as `(x0, y0, x1, y1)`. +fn artboard(app: &VectorcraftApp) -> (f64, f64, f64, f64) { + let r = app.session.doc().unwrap().doc.artboards[0].rect; + (r.x0, r.y0, r.x1, r.y1) +} + +/// Open With lists an importer by name; it opens an `.svg` with the importer, where File › Open +/// keeps the built-in SVG reader. +#[test] +fn open_with_lists_importers_and_opens_a_file_with_one() { + let dir = tmp_dir("with"); + let mut app = app(); + install(&mut app, &tpl::returning(&tpl::importer("test.ui.svgreader", r#"["svg","tui"]"#, "{}"), RECT)); + let menu = plugin::open_with_menu(); + let listed = menu.iter().any(|i| match i { + Item::Cmd(label, "file.openWith", p) => *label == "Test test.ui.svgreader…" && p["importer"] == "plugin.test.ui.svgreader", + _ => false, + }); + assert!(listed, "{menu:?}"); + + let svg = dir.join("a.svg"); + std::fs::write(&svg, r#""#).unwrap(); + let svg = svg.to_string_lossy().into_owned(); + crate::io::open_path(&mut app, &svg).unwrap(); + assert_eq!(artboard(&app), (0.0, 0.0, 50.0, 40.0), "File › Open: the built-in SVG reader"); + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.svgreader", "path": svg})).unwrap(); + assert_eq!(r["format"], "plugin.test.ui.svgreader"); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0), "Open With: the importer"); + assert_eq!(app.session.documents().len(), 2); + + // An importer is not a filter: Object › Plug-ins' command says where it is. + let e = app.run("plugin.dialog", json!({"id": "test.ui.svgreader"})).unwrap_err(); + assert!(e.contains("is an importer (File › Open With)"), "{e}"); + let e = app.run("file.openWith", json!({"path": svg})).unwrap_err(); + assert!(e.contains("missing `importer`"), "{e}"); + app.run("plugin.remove", json!({"id": "test.ui.svgreader"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File › Open reads a file of an extension only an importer lists, and refuses one past the +/// importer's input limit before reading it, as Open With does. +#[test] +fn file_open_refuses_an_importer_s_file_past_the_limit_before_reading_it() { + let dir = tmp_dir("limit"); + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.big", r#"["tub"]"#, "{}"), tpl::ECHO, "", 1)); + let small = dir.join("small.tub"); + std::fs::write(&small, RECT).unwrap(); + crate::io::open_path(&mut app, &small.to_string_lossy()).unwrap(); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0)); + + let big = dir.join("big.tub"); + std::fs::File::create(&big).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap(); + let big = big.to_string_lossy().into_owned(); + let e = crate::io::open_path(&mut app, &big).unwrap_err(); + assert!(e.contains("larger than"), "{e}"); + let e = app.run("file.openWith", json!({"importer": "plugin.test.ui.big", "path": big})).unwrap_err(); + assert!(e.contains("larger than"), "{e}"); + assert_eq!(app.session.documents().len(), 1); + app.run("plugin.remove", json!({"id": "test.ui.big"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 19d428340..925865744 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6282,3 +6282,8 @@ breakText Glyphs Глифи # 3D Box add-on 3D Box 3D кутия 3D Box Tool Инструмент за 3D кутия + +# Importer plug-ins (File › Open With, document.open) + Open With Отваряне с + No importer plug-ins installed Няма инсталирани плъгини за импортиране + Read files you open in its format and make documents from them Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 50553d5bf..c69f6ea9a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6329,3 +6329,8 @@ shape Open Otvoren # 3D Box add-on 3D Box 3D kutija 3D Box Tool Alat za 3D kutiju + +# Importer plug-ins (File › Open With, document.open) + Open With Otvori pomoću + No importer plug-ins installed Nisu instalirani dodaci za uvoz + Read files you open in its format and make documents from them Čitati datoteke koje otvarate u njegovom formatu i od njih praviti dokumente diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 324750c44..222971582 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6430,3 +6430,20 @@ breakText Glyphs Glifs # 3D Box add-on 3D Box Caixa 3D 3D Box Tool Eina Caixa 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Obre amb + No importer plug-ins installed No hi ha complements d’importació instal·lats + Read files you open in its format and make documents from them Llegir els fitxers que obriu en el seu format i fer-ne documents +@msg {_1} is an importer (File › Open With) {_1} és un importador (Fitxer › Obre amb) +@msg missing `importer` (see document.formats) falta `importer` (vegeu document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` ha de ser l’id de format d’un complement (vegeu document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` és un {_2}, no un importador +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}} +@msg the importer returned no objects l’importador no ha retornat cap objecte +@msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el fitxer supera els {MAX_INPUT_BYTES} bytes que llegeix un importador +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un importador declara d’1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` només un exportador o un importador declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un importador sempre rep el pressupost d’una importació: ometeu `budget` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 78e9cdd6e..eb3dd4da2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6444,3 +6444,20 @@ breakText Glyphs Glyfy # 3D Box add-on 3D Box 3D kvádr 3D Box Tool Nástroj 3D kvádr + +# Importer plug-ins (File › Open With, document.open) + Open With Otevřít pomocí + No importer plug-ins installed Nejsou nainstalovány žádné importní zásuvné moduly + Read files you open in its format and make documents from them Číst soubory, které otevřete v jeho formátu, a vytvářet z nich dokumenty +@msg {_1} is an importer (File › Open With) {_1} je importní zásuvný modul (Soubor › Otevřít pomocí) +@msg missing `importer` (see document.formats) chybí `importer` (viz document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` musí být ID formátu zásuvného modulu (viz document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ otevírá soubory {_1}, ne `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` je {_2}, ne importní zásuvný modul +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` je importní zásuvný modul: otevřete soubor pomocí document.open {{"importer"}"}} +@msg the importer returned no objects importní zásuvný modul nevrátil žádné objekty +@msg an importer returns `objects` and `warnings`, not `{key}` importní zásuvný modul vrací `objects` a `warnings`, ne `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads soubor je větší než {MAX_INPUT_BYTES} bajtů, které importní zásuvný modul čte +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` importní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` deklaruje jen exportní nebo importní zásuvný modul +@msg an importer always gets an import's budget: leave `budget` out importní zásuvný modul dostává vždy rozpočet importu: vynechte `budget` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f72f29a09..a36911a35 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6296,3 +6296,8 @@ breakText Glyphs Glyffer # 3D Box add-on 3D Box 3D-boks 3D Box Tool 3D-boksværktøj + +# Importer plug-ins (File › Open With, document.open) + Open With Åbn med + No importer plug-ins installed Ingen import-plug-ins installeret + Read files you open in its format and make documents from them Læse filer, du åbner i dens format, og lave dokumenter ud fra dem diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b22912c68..720f94a16 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6030,3 +6030,20 @@ breakText Glyphs Glyphen # 3D Box add-on 3D Box 3D-Quader 3D Box Tool 3D-Quader-Werkzeug + +# Importer plug-ins (File › Open With, document.open) + Open With Öffnen mit + No importer plug-ins installed Keine Import-Plug-ins installiert + Read files you open in its format and make documents from them Dateien lesen, die Sie in seinem Format öffnen, und daraus Dokumente erstellen +@msg {_1} is an importer (File › Open With) {_1} ist ein Import-Plug-in (Datei › Öffnen mit) +@msg missing `importer` (see document.formats) `importer` fehlt (siehe document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` muss die Format-ID eines Plug-ins sein (siehe document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ öffnet {_1}-Dateien, nicht `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` ist ein {_2}, kein Import-Plug-in +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` ist ein Import-Plug-in: Öffnen Sie eine Datei mit document.open {{"importer"}"}} +@msg the importer returned no objects das Import-Plug-in hat keine Objekte zurückgegeben +@msg an importer returns `objects` and `warnings`, not `{key}` ein Import-Plug-in gibt `objects` und `warnings` zurück, nicht `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads die Datei ist größer als die {MAX_INPUT_BYTES} Byte, die ein Import-Plug-in liest +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` ein Import-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an +@msg only an exporter or an importer declares `extensions` nur ein Export- oder Import-Plug-in gibt `extensions` an +@msg an importer always gets an import's budget: leave `budget` out ein Import-Plug-in erhält immer das Budget eines Imports: Lassen Sie `budget` weg diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 71e4f499f..22a6511d3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6396,3 +6396,20 @@ breakText Glyphs Γλύφοι # 3D Box add-on 3D Box Κουτί 3D 3D Box Tool Εργαλείο κουτιού 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Άνοιγμα με + No importer plug-ins installed Δεν έχουν εγκατασταθεί πρόσθετα εισαγωγής + Read files you open in its format and make documents from them Ανάγνωση των αρχείων που ανοίγετε στη μορφή του και δημιουργία εγγράφων από αυτά +@msg {_1} is an importer (File › Open With) το {_1} είναι πρόσθετο εισαγωγής (Αρχείο › Άνοιγμα με) +@msg missing `importer` (see document.formats) λείπει το `importer` (δείτε document.formats) +@msg `importer` must be a plug-in format id (see document.formats) το `importer` πρέπει να είναι αναγνωριστικό μορφής πρόσθετου (δείτε document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` το «{shown}» ανοίγει αρχεία {_1}, όχι `{_2}` +@msg `{_1}` is a {_2}, not an importer το `{_1}` είναι {_2}, όχι πρόσθετο εισαγωγής +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} το `{_1}` είναι πρόσθετο εισαγωγής: ανοίξτε ένα αρχείο με document.open {{"importer"}"}} +@msg the importer returned no objects το πρόσθετο εισαγωγής δεν επέστρεψε κανένα αντικείμενο +@msg an importer returns `objects` and `warnings`, not `{key}` ένα πρόσθετο εισαγωγής επιστρέφει `objects` και `warnings`, όχι `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads το αρχείο είναι μεγαλύτερο από τα {MAX_INPUT_BYTES} byte που διαβάζει ένα πρόσθετο εισαγωγής +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εισαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` μόνο ένα πρόσθετο εξαγωγής ή εισαγωγής δηλώνει `extensions` +@msg an importer always gets an import's budget: leave `budget` out ένα πρόσθετο εισαγωγής παίρνει πάντα τον προϋπολογισμό μιας εισαγωγής: παραλείψτε το `budget` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 72ad8d982..d574f8efa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6386,3 +6386,20 @@ breakText Glyphs Glifos # 3D Box add-on 3D Box Caja 3D 3D Box Tool Herramienta Caja 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Abrir con + No importer plug-ins installed No hay plugins de importación instalados + Read files you open in its format and make documents from them Leer los archivos que abre en su formato y crear documentos a partir de ellos +@msg {_1} is an importer (File › Open With) {_1} es un plugin de importación (Archivo › Abrir con) +@msg missing `importer` (see document.formats) falta `importer` (consulte document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` debe ser el id de formato de un plugin (consulte document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» abre archivos {_1}, no `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` es un {_2}, no un plugin de importación +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` es un plugin de importación: abra un archivo con document.open {{"importer"}"}} +@msg the importer returned no objects el plugin de importación no devolvió ningún objeto +@msg an importer returns `objects` and `warnings`, not `{key}` un plugin de importación devuelve `objects` y `warnings`, no `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el archivo supera los {MAX_INPUT_BYTES} bytes que lee un plugin de importación +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de importación declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` solo un plugin de exportación o de importación declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un plugin de importación siempre recibe el presupuesto de una importación: omita `budget` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f4eeaaff1..399c8e5fa 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6479,3 +6479,20 @@ breakText Glyphs Merkit # 3D Box add-on 3D Box 3D-laatikko 3D Box Tool 3D-laatikkotyökalu + +# Importer plug-ins (File › Open With, document.open) + Open With Avaa ohjelmalla + No importer plug-ins installed Tuontilaajennuksia ei ole asennettu + Read files you open in its format and make documents from them Lukea sen muodossa avaamasi tiedostot ja tehdä niistä asiakirjoja +@msg {_1} is an importer (File › Open With) {_1} on tuontilaajennus (Tiedosto › Avaa ohjelmalla) +@msg missing `importer` (see document.formats) `importer` puuttuu (katso document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` on oltava laajennuksen muototunnus (katso document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” avaa {_1}-tiedostoja, ei `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` on {_2}, ei tuontilaajennus +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` on tuontilaajennus: avaa tiedosto komennolla document.open {{"importer"}"}} +@msg the importer returned no objects tuontilaajennus ei palauttanut objekteja +@msg an importer returns `objects` and `warnings`, not `{key}` tuontilaajennus palauttaa `objects` ja `warnings`, ei `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads tiedosto on suurempi kuin tuontilaajennuksen lukemat {MAX_INPUT_BYTES} tavua +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` tuontilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` vain vienti- tai tuontilaajennus ilmoittaa `extensions` +@msg an importer always gets an import's budget: leave `budget` out tuontilaajennus saa aina tuonnin budjetin: jätä `budget` pois diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d9e77d68d..78215fec9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6439,3 +6439,20 @@ breakText Glyphs Glyphes # 3D Box add-on 3D Box Boîte 3D 3D Box Tool Outil Boîte 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Ouvrir avec + No importer plug-ins installed Aucun module externe d’importation installé + Read files you open in its format and make documents from them Lire les fichiers que vous ouvrez dans son format et en faire des documents +@msg {_1} is an importer (File › Open With) {_1} est un module externe d’importation (Fichier › Ouvrir avec) +@msg missing `importer` (see document.formats) `importer` manquant (voir document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` doit être l’identifiant de format d’un module externe (voir document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` « {shown} » ouvre les fichiers {_1}, pas `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` est un {_2}, pas un module externe d’importation +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` est un module externe d’importation : ouvrez un fichier avec document.open {{"importer"}"}} +@msg the importer returned no objects le module externe d’importation n’a renvoyé aucun objet +@msg an importer returns `objects` and `warnings`, not `{key}` un module externe d’importation renvoie `objects` et `warnings`, pas `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads le fichier dépasse les {MAX_INPUT_BYTES} octets qu’un module externe d’importation lit +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’importation déclare de 1 à {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` seul un module externe d’exportation ou d’importation déclare `extensions` +@msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b5791f4a4..38ac80141 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6892,7 +6892,7 @@ @msg Offset Path: the path is too complex to offset ऑफसेट पाथ: पाथ ऑफसेट करने के लिए बहुत जटिल है @msg on must be true or false on: पर सच होना चाहिए या गलत होना चाहिए @msg only a dock group above or below others collapses, not one beside another केवल दूसरों के ऊपर या नीचे एक डॉक समूह गिर जाता है, एक दूसरे के बगल में नहीं -@msg only an exporter declares `extensions` केवल निर्यातक `extensions` घोषित करता है +@msg only an exporter or an importer declares `extensions` केवल एक्सपोर्टर या इंपोर्टर `extensions` घोषित करता है @msg only an object of the Global Edit other than the one it started from can be left out केवल ग्लोबल एडिट का एक ऑब्जेक्ट, इसके अलावा इसे शुरू किया जा सकता है, छोड़ दिया जा सकता है @msg only groups, layers, type and images have a Contents (Characters) row केवल समूहों, लेयर्स, प्रकार और इमेज में एक सामग्री (Character) पंक्ति है @msg only http, https and mailto URLs open in the browser, not “{url}” केवल http, https और Mailto URL ब्राउज़र में खुलते हैं, न कि "{url}" @@ -7927,3 +7927,19 @@ swatch White, Black सफ़ेद, काला swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस + +# Importer plug-ins (File › Open With, document.open) + Open With इसके साथ खोलें + No importer plug-ins installed कोई इंपोर्टर प्लग-इन स्थापित नहीं है + Read files you open in its format and make documents from them उसके फ़ॉर्मेट में खोली गई फ़ाइलें पढ़ना और उनसे डॉक्यूमेंट बनाना +@msg {_1} is an importer (File › Open With) {_1} एक इंपोर्टर है (फ़ाइल › इसके साथ खोलें) +@msg missing `importer` (see document.formats) `importer` नहीं दिया गया (document.formats देखें) +@msg `importer` must be a plug-in format id (see document.formats) `importer` किसी प्लग-इन का फ़ॉर्मेट id होना चाहिए (document.formats देखें) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} फ़ाइलें खोलता है, `{_2}` नहीं +@msg `{_1}` is a {_2}, not an importer `{_1}` एक {_2} है, इंपोर्टर नहीं +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` एक इंपोर्टर है: document.open {{"importer"}"}} से फ़ाइल खोलें +@msg the importer returned no objects इंपोर्टर ने कोई ऑब्जेक्ट नहीं लौटाया +@msg an importer returns `objects` and `warnings`, not `{key}` इंपोर्टर `objects` और `warnings` लौटाता है, `{key}` नहीं +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads फ़ाइल उन {MAX_INPUT_BYTES} बाइट्स से बड़ी है जो इंपोर्टर पढ़ता है +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` इंपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है +@msg an importer always gets an import's budget: leave `budget` out इंपोर्टर को हमेशा इंपोर्ट का बजट मिलता है: `budget` छोड़ दें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 58d649442..a4404ede3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6428,3 +6428,20 @@ breakText Glyphs Glifák # 3D Box add-on 3D Box 3D doboz 3D Box Tool 3D doboz eszköz + +# Importer plug-ins (File › Open With, document.open) + Open With Megnyitás ezzel + No importer plug-ins installed Nincs telepített importáló bővítmény + Read files you open in its format and make documents from them Beolvashatja a formátumában megnyitott fájlokat, és dokumentumokat készíthet belőlük +@msg {_1} is an importer (File › Open With) importáló bővítmény: {_1} (Fájl › Megnyitás ezzel) +@msg missing `importer` (see document.formats) hiányzik az `importer` (lásd document.formats) +@msg `importer` must be a plug-in format id (see document.formats) az `importer` egy bővítmény formátumazonosítója kell legyen (lásd document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}” {_1} fájlokat nyit meg, nem `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}`: {_2}, nem importáló bővítmény +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` importáló bővítmény: nyisson meg egy fájlt a document.open {{"importer"}"}} paranccsal +@msg the importer returned no objects az importáló bővítmény nem adott vissza objektumot +@msg an importer returns `objects` and `warnings`, not `{key}` az importáló bővítmény `objects` és `warnings` értéket ad vissza, nem `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads a fájl nagyobb annál a {MAX_INPUT_BYTES} bájtnál, amit egy importáló bővítmény beolvas +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` az importáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg +@msg only an exporter or an importer declares `extensions` csak exportáló vagy importáló bővítmény adhat meg `extensions` értéket +@msg an importer always gets an import's budget: leave `budget` out az importáló bővítmény mindig egy importálás keretét kapja: hagyja el a `budget` kulcsot diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6211e49da..763c47a2a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6296,3 +6296,8 @@ breakText Glyphs Glif # 3D Box add-on 3D Box Kotak 3D 3D Box Tool Alat Kotak 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Buka Dengan + No importer plug-ins installed Tidak ada plug-in impor yang terinstal + Read files you open in its format and make documents from them Membaca file yang Anda buka dalam formatnya dan membuat dokumen darinya diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d11fcb22d..f4bde607f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6064,3 +6064,20 @@ breakText Glyphs Glifi # 3D Box add-on 3D Box Scatola 3D 3D Box Tool Strumento Scatola 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Apri con + No importer plug-ins installed Nessun plug-in di importazione installato + Read files you open in its format and make documents from them Leggere i file che apri nel suo formato e crearne documenti +@msg {_1} is an importer (File › Open With) {_1} è un plug-in di importazione (File › Apri con) +@msg missing `importer` (see document.formats) manca `importer` (vedi document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` deve essere l’id di formato di un plug-in (vedi document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» apre file {_1}, non `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` è un {_2}, non un plug-in di importazione +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` è un plug-in di importazione: apri un file con document.open {{"importer"}"}} +@msg the importer returned no objects il plug-in di importazione non ha restituito alcun oggetto +@msg an importer returns `objects` and `warnings`, not `{key}` un plug-in di importazione restituisce `objects` e `warnings`, non `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads il file supera i {MAX_INPUT_BYTES} byte che un plug-in di importazione legge +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di importazione dichiara da 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` solo un plug-in di esportazione o di importazione dichiara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un plug-in di importazione riceve sempre il budget di un’importazione: ometti `budget` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6bb917eec..9415aad1f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6129,3 +6129,20 @@ breakText Glyphs 字形 # 3D Box add-on 3D Box 3Dボックス 3D Box Tool 3Dボックスツール + +# Importer plug-ins (File › Open With, document.open) + Open With このアプリケーションで開く + No importer plug-ins installed 読み込みプラグインはインストールされていません + Read files you open in its format and make documents from them 独自形式で開いたファイルの読み取りとドキュメントの作成 +@msg {_1} is an importer (File › Open With) {_1} は読み込みプラグインです (「ファイル › このアプリケーションで開く」から使用してください) +@msg missing `importer` (see document.formats) `importer` がありません (document.formats を参照) +@msg `importer` must be a plug-in format id (see document.formats) `importer` にはプラグインの形式 ID を指定してください (document.formats を参照) +@msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」が開くのは {_1} ファイルで、`{_2}` ではありません +@msg `{_1}` is a {_2}, not an importer `{_1}` は {_2} で、読み込みプラグインではありません +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` は読み込みプラグインです: document.open {{"importer"}"}} でファイルを開いてください +@msg the importer returned no objects 読み込みプラグインがオブジェクトを返しませんでした +@msg an importer returns `objects` and `warnings`, not `{key}` 読み込みプラグインが返すのは `objects` と `warnings` で、`{key}` ではありません +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads ファイルが読み込みプラグインの読み取る {MAX_INPUT_BYTES} バイトを超えています +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 読み込みプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します +@msg only an exporter or an importer declares `extensions` `extensions` を宣言できるのは書き出しプラグインと読み込みプラグインだけです +@msg an importer always gets an import's budget: leave `budget` out 読み込みプラグインには常に読み込みの予算が割り当てられます: `budget` は省略してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 483955091..c6db1fce7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6456,3 +6456,20 @@ font-script Japanese იაპონური # 3D Box add-on 3D Box 3D ყუთი 3D Box Tool 3D ყუთის ხელსაწყო + +# Importer plug-ins (File › Open With, document.open) + Open With გახსნა პროგრამით + No importer plug-ins installed იმპორტის პლაგინები არ არის დაყენებული + Read files you open in its format and make documents from them მის ფორმატში გახსნილი ფაილების წაკითხვა და მათგან დოკუმენტების შექმნა +@msg {_1} is an importer (File › Open With) {_1} იმპორტის პლაგინია (ფაილი › გახსნა პროგრამით) +@msg missing `importer` (see document.formats) `importer` აკლია (იხ. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` უნდა იყოს პლაგინის ფორმატის ID (იხ. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ ხსნის {_1} ფაილებს და არა `{_2}`-ს +@msg `{_1}` is a {_2}, not an importer `{_1}` არის {_2} და არა იმპორტის პლაგინი +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` იმპორტის პლაგინია: გახსენით ფაილი document.open {{"importer"}"}}-ით +@msg the importer returned no objects იმპორტის პლაგინმა ობიექტები არ დააბრუნა +@msg an importer returns `objects` and `warnings`, not `{key}` იმპორტის პლაგინი აბრუნებს `objects`-ს და `warnings`-ს და არა `{key}`-ს +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads ფაილი აღემატება {MAX_INPUT_BYTES} ბაიტს, რომელსაც იმპორტის პლაგინი კითხულობს +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` იმპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს +@msg only an exporter or an importer declares `extensions` `extensions`-ს მხოლოდ ექსპორტის ან იმპორტის პლაგინი აცხადებს +@msg an importer always gets an import's budget: leave `budget` out იმპორტის პლაგინი ყოველთვის იღებს იმპორტის ბიუჯეტს: გამოტოვეთ `budget` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9a6a465c9..090252435 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6025,3 +6025,20 @@ breakText Glyphs 글리프 # 3D Box add-on 3D Box 3D 상자 3D Box Tool 3D 상자 도구 + +# Importer plug-ins (File › Open With, document.open) + Open With 연결 프로그램 + No importer plug-ins installed 설치된 가져오기 플러그인 없음 + Read files you open in its format and make documents from them 자체 형식으로 연 파일을 읽고 문서로 만들기 +@msg {_1} is an importer (File › Open With) {_1}은(는) 가져오기 플러그인입니다(파일 › 연결 프로그램) +@msg missing `importer` (see document.formats) `importer`가 없습니다(document.formats 참조) +@msg `importer` must be a plug-in format id (see document.formats) `importer`는 플러그인의 형식 ID여야 합니다(document.formats 참조) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}”은(는) {_1} 파일을 엽니다. `{_2}`은(는) 열지 않습니다 +@msg `{_1}` is a {_2}, not an importer `{_1}`은(는) {_2}이며 가져오기 플러그인이 아닙니다 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}`은(는) 가져오기 플러그인입니다: document.open {{"importer"}"}}로 파일을 여세요 +@msg the importer returned no objects 가져오기 플러그인이 개체를 반환하지 않았습니다 +@msg an importer returns `objects` and `warnings`, not `{key}` 가져오기 플러그인은 `{key}`가 아닌 `objects`와 `warnings`를 반환합니다 +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 파일이 가져오기 플러그인이 읽는 {MAX_INPUT_BYTES}바이트보다 큽니다 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 가져오기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다 +@msg only an exporter or an importer declares `extensions` `extensions`는 내보내기 또는 가져오기 플러그인만 선언합니다 +@msg an importer always gets an import's budget: leave `budget` out 가져오기 플러그인은 항상 가져오기 예산을 받습니다: `budget`을 생략하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0328f1fc5..875bc733c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6342,3 +6342,8 @@ breakText Glyphs Glyphs # 3D Box add-on 3D Box 3D-doos 3D Box Tool 3D-doosgereedschap + +# Importer plug-ins (File › Open With, document.open) + Open With Openen met + No importer plug-ins installed Geen importplug-ins geïnstalleerd + Read files you open in its format and make documents from them Bestanden lezen die u in zijn indeling opent en er documenten van maken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f98e35520..37ad72246 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6299,3 +6299,20 @@ breakText Glyphs Glify # 3D Box add-on 3D Box Bryła 3D 3D Box Tool Narzędzie Bryła 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Otwórz za pomocą + No importer plug-ins installed Brak zainstalowanych wtyczek importu + Read files you open in its format and make documents from them Odczytywać pliki otwierane w jego formacie i tworzyć z nich dokumenty +@msg {_1} is an importer (File › Open With) {_1} to wtyczka importu (Plik › Otwórz za pomocą) +@msg missing `importer` (see document.formats) brak `importer` (zobacz document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` musi być identyfikatorem formatu wtyczki (zobacz document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}” otwiera pliki {_1}, a nie `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` to {_2}, a nie wtyczka importu +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` to wtyczka importu: otwórz plik za pomocą document.open {{"importer"}"}} +@msg the importer returned no objects wtyczka importu nie zwróciła żadnych obiektów +@msg an importer returns `objects` and `warnings`, not `{key}` wtyczka importu zwraca `objects` i `warnings`, a nie `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads plik jest większy niż {MAX_INPUT_BYTES} bajtów, które odczytuje wtyczka importu +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka importu deklaruje od 1 do {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` deklaruje tylko wtyczka eksportu lub importu +@msg an importer always gets an import's budget: leave `budget` out wtyczka importu zawsze dostaje budżet importu: pomiń `budget` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ce6919f8d..8510a00c7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6517,3 +6517,20 @@ swatch Yellow Green Verde-amarelado # 3D Box add-on 3D Box Caixa 3D 3D Box Tool Ferramenta Caixa 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Abrir Com + No importer plug-ins installed Nenhum plug-in de importação instalado + Read files you open in its format and make documents from them Ler os arquivos que você abre no formato dele e criar documentos a partir deles +@msg {_1} is an importer (File › Open With) {_1} é um plug-in de importação (Arquivo › Abrir Com) +@msg missing `importer` (see document.formats) falta `importer` (consulte document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` deve ser o id de formato de um plug-in (consulte document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” abre arquivos {_1}, não `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` é um {_2}, não um plug-in de importação +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` é um plug-in de importação: abra um arquivo com document.open {{"importer"}"}} +@msg the importer returned no objects o plug-in de importação não retornou nenhum objeto +@msg an importer returns `objects` and `warnings`, not `{key}` um plug-in de importação retorna `objects` e `warnings`, não `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads o arquivo é maior que os {MAX_INPUT_BYTES} bytes que um plug-in de importação lê +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de importação declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` só um plug-in de exportação ou de importação declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out um plug-in de importação sempre recebe o orçamento de uma importação: omita `budget` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a1ae4746c..4c6aa7564 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6533,3 +6533,20 @@ shape Open Открытый # 3D Box add-on 3D Box 3D-параллелепипед 3D Box Tool Инструмент «3D-параллелепипед» + +# Importer plug-ins (File › Open With, document.open) + Open With Открыть с помощью + No importer plug-ins installed Модули импорта не установлены + Read files you open in its format and make documents from them Читать файлы, открываемые в его формате, и создавать из них документы +@msg {_1} is an importer (File › Open With) {_1} — модуль импорта (Файл › Открыть с помощью) +@msg missing `importer` (see document.formats) не указан `importer` (см. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` должен быть идентификатором формата модуля (см. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» открывает файлы {_1}, а не `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` — это {_2}, а не модуль импорта +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` — модуль импорта: откройте файл с помощью document.open {{"importer"}"}} +@msg the importer returned no objects модуль импорта не вернул ни одного объекта +@msg an importer returns `objects` and `warnings`, not `{key}` модуль импорта возвращает `objects` и `warnings`, а не `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads файл больше {MAX_INPUT_BYTES} байт, которые читает модуль импорта +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` модуль импорта объявляет от 1 до {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` объявляет только модуль экспорта или импорта +@msg an importer always gets an import's budget: leave `budget` out модуль импорта всегда получает бюджет импорта: не указывайте `budget` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b13141b8e..06d3ea547 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -7481,7 +7481,7 @@ digits Hindi Hindi @msg offset out of range förskjutningen ligger utanför intervallet @msg Offset Path: {e} Förskjut bana: {e} @msg Offset Path: the path is too complex to offset Förskjut bana: banan är för komplex för att förskjutas -@msg only an exporter declares `extensions` endast en exportfunktion deklarerar `extensions` +@msg only an exporter or an importer declares `extensions` endast en exportfunktion eller importfunktion deklarerar `extensions` @msg only groups, layers, type and images have a Contents (Characters) row endast grupper, lager, text och bilder har en rad för Innehåll (Tecken) @msg only interior mesh lines can be deleted endast inre nätlinjer kan raderas @msg only solid colours can be added (not gradients or patterns) endast enhetliga färger kan läggas till (inte gradienter eller mönster) @@ -7932,3 +7932,19 @@ digits Hindi Hindi @msg x and y must be given together x och y måste anges tillsammans @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End) @msg zoom must be positive zoom måste vara positiv + +# Importer plug-ins (File › Open With, document.open) + Open With Öppna med + No importer plug-ins installed Inga importplugins är installerade + Read files you open in its format and make documents from them Läsa filer som du öppnar i dess format och skapa dokument av dem +@msg {_1} is an importer (File › Open With) {_1} är ett importinsticksprogram (Fil › Öppna med) +@msg missing `importer` (see document.formats) `importer` saknas (se document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` måste vara ett formatid för ett plugin (se document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” öppnar {_1}-filer, inte `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` är av typen {_2}, inte en importfunktion +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` är en importfunktion: öppna en fil med document.open {{"importer"}"}} +@msg the importer returned no objects importfunktionen returnerade inga objekt +@msg an importer returns `objects` and `warnings`, not `{key}` en importfunktion returnerar `objects` och `warnings`, inte `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads filen är större än de {MAX_INPUT_BYTES} byte som en importfunktion läser +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` en importfunktion anger 1 till {MAX_EXTENSIONS} `extensions` +@msg an importer always gets an import's budget: leave `budget` out en importfunktion får alltid en imports budget: utelämna `budget` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d3fc04823..995ed31ca 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6401,3 +6401,20 @@ breakText Glyphs Glifler # 3D Box add-on 3D Box 3B Kutu 3D Box Tool 3B Kutu Aracı + +# Importer plug-ins (File › Open With, document.open) + Open With Birlikte Aç + No importer plug-ins installed İçe aktarma eklentisi yüklü değil + Read files you open in its format and make documents from them Kendi biçiminde açtığınız dosyaları okuma ve bunlardan belge oluşturma +@msg {_1} is an importer (File › Open With) {_1} bir içe aktarma eklentisidir (Dosya › Birlikte Aç) +@msg missing `importer` (see document.formats) `importer` eksik (bkz. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` bir eklentinin biçim kimliği olmalıdır (bkz. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} dosyalarını açar, `{_2}` dosyasını değil +@msg `{_1}` is a {_2}, not an importer `{_1}` bir {_2}, içe aktarma eklentisi değil +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` bir içe aktarma eklentisidir: dosyayı document.open {{"importer"}"}} ile açın +@msg the importer returned no objects içe aktarma eklentisi hiç nesne döndürmedi +@msg an importer returns `objects` and `warnings`, not `{key}` içe aktarma eklentisi `{key}` değil, `objects` ve `warnings` döndürür +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads dosya, içe aktarma eklentisinin okuduğu {MAX_INPUT_BYTES} bayttan büyük +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` içe aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir +@msg only an exporter or an importer declares `extensions` `extensions` yalnızca dışa veya içe aktarma eklentisi bildirir +@msg an importer always gets an import's budget: leave `budget` out içe aktarma eklentisi her zaman bir içe aktarmanın bütçesini alır: `budget` değerini çıkarın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c256b0064..5044fe895 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6044,3 +6044,20 @@ breakText Glyphs Гліфи # 3D Box add-on 3D Box 3D-паралелепіпед 3D Box Tool Інструмент «3D-паралелепіпед» + +# Importer plug-ins (File › Open With, document.open) + Open With Відкрити за допомогою + No importer plug-ins installed Модулі імпорту не встановлено + Read files you open in its format and make documents from them Читати файли, які ви відкриваєте в його форматі, і створювати з них документи +@msg {_1} is an importer (File › Open With) {_1} - модуль імпорту (Файл › Відкрити за допомогою) +@msg missing `importer` (see document.formats) не вказано `importer` (див. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` має бути ідентифікатором формату модуля (див. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» відкриває файли {_1}, а не `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` - це {_2}, а не модуль імпорту +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` - модуль імпорту: відкрийте файл за допомогою document.open {{"importer"}"}} +@msg the importer returned no objects модуль імпорту не повернув жодного об’єкта +@msg an importer returns `objects` and `warnings`, not `{key}` модуль імпорту повертає `objects` і `warnings`, а не `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads файл більший за {MAX_INPUT_BYTES} байтів, які читає модуль імпорту +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` модуль імпорту оголошує від 1 до {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` оголошує лише модуль експорту або імпорту +@msg an importer always gets an import's budget: leave `budget` out модуль імпорту завжди отримує бюджет імпорту: не вказуйте `budget` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 887d9a0f6..758e6799d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6045,3 +6045,20 @@ breakText Glyphs Glyph # 3D Box add-on 3D Box Hộp 3D 3D Box Tool Công cụ Hộp 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Mở bằng + No importer plug-ins installed Không có plug-in nhập được cài đặt + Read files you open in its format and make documents from them Đọc các tệp bạn mở theo định dạng của nó và tạo tài liệu từ chúng +@msg {_1} is an importer (File › Open With) {_1} là plug-in nhập (Tệp › Mở bằng) +@msg missing `importer` (see document.formats) thiếu `importer` (xem document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` phải là id định dạng của một plug-in (xem document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” mở tệp {_1}, không phải `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` là {_2}, không phải plug-in nhập +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` là plug-in nhập: hãy mở tệp bằng document.open {{"importer"}"}} +@msg the importer returned no objects plug-in nhập không trả về đối tượng nào +@msg an importer returns `objects` and `warnings`, not `{key}` plug-in nhập trả về `objects` và `warnings`, không phải `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads tệp lớn hơn {MAX_INPUT_BYTES} byte mà plug-in nhập đọc +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` plug-in nhập khai báo từ 1 đến {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` chỉ plug-in xuất hoặc nhập mới khai báo `extensions` +@msg an importer always gets an import's budget: leave `budget` out plug-in nhập luôn nhận ngân sách của một lần nhập: hãy bỏ `budget` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d99db0948..a43d1a88b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6285,3 +6285,20 @@ breakText Glyphs 字形 # 3D Box add-on 3D Box 3D 盒子 3D Box Tool 3D 盒子工具 + +# Importer plug-ins (File › Open With, document.open) + Open With 打开方式 + No importer plug-ins installed 未安装导入增效工具 + Read files you open in its format and make documents from them 读取以其格式打开的文件并据此创建文档 +@msg {_1} is an importer (File › Open With) {_1} 是导入增效工具 (文件 › 打开方式) +@msg missing `importer` (see document.formats) 缺少 `importer` (参见 document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` 必须是增效工具的格式 ID (参见 document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” 打开的是 {_1} 文件,不是 `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` 是 {_2},不是导入增效工具 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是导入增效工具:请用 document.open {{"importer"}"}} 打开文件 +@msg the importer returned no objects 导入增效工具未返回任何对象 +@msg an importer returns `objects` and `warnings`, not `{key}` 导入增效工具返回 `objects` 和 `warnings`,不是 `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 文件超过导入增效工具读取的 {MAX_INPUT_BYTES} 字节 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 导入增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions` +@msg only an exporter or an importer declares `extensions` 只有导出或导入增效工具才声明 `extensions` +@msg an importer always gets an import's budget: leave `budget` out 导入增效工具始终获得导入的预算:请省略 `budget` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb6b40a80..863bd31ba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6082,3 +6082,20 @@ breakText Glyphs 字符 # 3D Box add-on 3D Box 3D 盒子 3D Box Tool 3D 盒子工具 + +# Importer plug-ins (File › Open With, document.open) + Open With 開啟方式 + No importer plug-ins installed 未安裝匯入增效模組 + Read files you open in its format and make documents from them 讀取以其格式開啟的檔案並據此建立文件 +@msg {_1} is an importer (File › Open With) {_1} 是匯入增效模組 (檔案 › 開啟方式) +@msg missing `importer` (see document.formats) 未提供 `importer` (請參閱 document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` 必須是增效模組的格式 ID (請參閱 document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」開啟的是 {_1} 檔案,不是 `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` 是 {_2},不是匯入增效模組 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是匯入增效模組:請用 document.open {{"importer"}"}} 開啟檔案 +@msg the importer returned no objects 匯入增效模組未傳回任何物件 +@msg an importer returns `objects` and `warnings`, not `{key}` 匯入增效模組傳回 `objects` 和 `warnings`,不是 `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 檔案超過匯入增效模組讀取的 {MAX_INPUT_BYTES} 位元組 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 匯入增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions` +@msg only an exporter or an importer declares `extensions` 只有匯出或匯入增效模組才宣告 `extensions` +@msg an importer always gets an import's budget: leave `budget` out 匯入增效模組一律取得匯入的預算:請勿指定 `budget` diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d1e34e38b..41694fdbe 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -24,7 +24,7 @@ pub(crate) use export::{ pub(crate) use open::{EXPORT_PRESETS, import_presets, opened}; pub use open::{ MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, - open_path, open_reporting, opens_as, place_bytes, recent_files, report_open_error, + open_path, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error, }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 85ce45c40..f5bb617df 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -174,6 +174,7 @@ fn permission_words(name: &str) -> String { Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), Some(permissions::Permission::EffectRender) => tl!("Change how the objects its effect is applied to are drawn"), Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), + Some(permissions::Permission::ImportRead) => tl!("Read files you open in its format and make documents from them"), None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), }; words.to_string() @@ -256,6 +257,70 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { open_reporting(app, &path).map(|_| ()) } +/// File › Open With: opens the file `p` names (`path`), else one picked among the files with the +/// extensions the importer plug-in `importer` lists, with that importer, also when a built-in +/// format reads the extension (`document.open {importer}`); a failure is reported as File › Open +/// reports it. The web's file picker can't carry the importer: there File › Open's picker opens, +/// and the file opens as File › Open opens it. +pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { + let importer = p.get("importer").and_then(Value::as_str).ok_or("missing `importer` (see document.formats)")?; + let plugin = vectorcraft_plugins::import::named(importer).map_err(|e| e.to_string())?; + let path = match p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()) { + Some(path) => path.to_string(), + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => { + // The importer's format: its name and extensions, made once for each installation. + let filters = fileio::format(&vectorcraft_plugins::export::format_id(plugin.id())) + .filter(|f| f.read) + .map(|f| FilePick::filter(f.label, f.extensions)) + .into_iter() + .collect(); + pick_open(app, &FilePick { filters, folder: open_dialog_folder(app), ..Default::default() })? + } + }; + let opened = vectorcraft_engine::file_access::check_read(&path) + .and_then(|()| check_import_size(&path, true)) + .and_then(|()| read(app, &path)) + .and_then(|bytes| { + open_document(app, &path, &bytes, Some(path.clone()), &json!({ "importer": vectorcraft_plugins::export::format_id(plugin.id()) })) + }); + if let Err(e) = &opened { + report_open_error(app, &fileio::file_name(&path), e); + } + opened +} + +/// A file at `path` that only an importer plug-in reads (with `explicit`, the one named for it), +/// larger than the importer's input limit: an error before the file is read. A file whose start +/// shows a built-in format passes when no importer was named, since a built-in reader opens it. +#[cfg(not(target_arch = "wasm32"))] +fn check_import_size(path: &str, explicit: bool) -> Result<(), String> { + use std::io::Read as _; + use vectorcraft_plugins::import; + let ext = fileio::extension(path); + if !explicit && (fileio::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) { + return Ok(()); + } + if std::fs::metadata(path).map_or(true, |m| m.len() <= import::MAX_INPUT_BYTES as u64) { + return Ok(()); + } + if !explicit { + let mut head = Vec::new(); + if let Ok(f) = std::fs::File::open(path) { + let _ = f.take(64 << 10).read_to_end(&mut head); + } + if fileio::detect(path, &head).is_some() { + return Ok(()); + } + } + Err(format!("can't open `{}`: {}", fileio::file_name(path), import::too_large())) +} + +#[cfg(target_arch = "wasm32")] +fn check_import_size(_: &str, _: bool) -> Result<(), String> { + Ok(()) +} + /// A file the user asked to open (File › Open, Open Recent, the Home screen, a drop, the Finder) /// couldn't be: say so in a dialog as well as the status bar, which is easily missed (#861). pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { @@ -353,6 +418,8 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) note_recent(app, path); return Ok(Value::Null); } + // A file only an importer plug-in reads is not read past the importer's input limit. + check_import_size(path, false)?; let mut bytes = read(app, path)?; if background && opens_as(path).is_none() { // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index b8d314346..d2f460834 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -138,16 +138,17 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool { ]; SLOTS.iter().any(|s| id.starts_with(s)) || saved_selection_slot(id).is_some() - || matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom") + || matches!(id, "text.setStyle" | "plugin.dialog" | "file.openWith" | "window.toolbar.custom") || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) } -/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or -/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds -/// while another thread installs or removes plug-ins. +/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter, an +/// Effect › Plug-ins effect or a File › Open With importer), by the plug-in's own name? Told by the +/// item itself, so it holds while another thread installs or removes plug-ins. pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { - id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() + matches!(id, "plugin.dialog" | "file.openWith") + || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() } /// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 8ccb80c04..8031a98da 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -110,6 +110,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_path(app, &path), None => io::open_dialog(app).map(|_| Value::Null), }, + "file.openWith" => io::open_with(app, p), // Saves write through the app (save panel, download); with no path known the panel and the // format's options dialog ask first. "file.save" => io::save(app, SaveMode::Save, p, true), diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 3a55ab37c..46d695f16 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -28,6 +28,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("New…", "file.newDialog"), c("New from Template…", "file.newFromTemplate"), c("Open…", "file.open"), + sub("Open With", crate::dialogs::plugin::open_with_menu()), sub("Open Recent Files", { let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); v.push(Sep); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f4b29416d..5505556d1 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -30,6 +30,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+O", "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", ), + ( + "file.openWith", + "Open With", + "", + "{importer: \"plugin.\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → document.open's result | null while the picker is open", + ), ( "file.save", "Save", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 92314bfba..28a4d9d11 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -68,6 +68,7 @@ pub(crate) enum Entry { /// document active at the time has closed meanwhile. const DOCUMENT_FREE: &[&str] = &[ "file.open", + "file.openWith", "file.newFromTemplate", "ui.installPlugin", "ui.checkScript", diff --git a/docs/gaps.md b/docs/gaps.md index 6780782ab..e2c7bfed7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -739,9 +739,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G17 Ecosystem: third-party plug-ins - **Missing:** Illustrator's ecosystem is a C++ plug-in SDK and a large commercial plug-in market. VectorCraft - has sandboxed WebAssembly plug-ins for object filters, live effects and file formats it writes - ([plugins.md](plugins.md)) and MCP; missing are tool plug-ins, panel plug-ins, importers, a plug-in directory and + has sandboxed WebAssembly plug-ins for object filters, live effects and file formats it writes or reads + ([plugins.md](plugins.md)) and MCP; missing are tool plug-ins, panel plug-ins, a plug-in directory and any third-party plug-ins (#710). +- **Done:** importer plug-ins (`"kind": "importer"`). A plug-in adds a read-only file format that File › Open lists + and File › Open With names; `document.open` uses it for a file no built-in format reads, or when `importer` names it. + It is given at most 128 MiB of the file and returns paths and compound paths, checked as a filter's output is, which + become a new document; nothing opens when it fails, declines or is cancelled. Engine, unit and headless Open With + regressions. - **Done:** exporter plug-ins (`"kind": "exporter"`). A plug-in adds a write-only file format that File › Export › Export As lists after the built-in ones and `document.export` writes (`plugin.`, or a path ending in one of its extensions). It is given the document as the native file's JSON and its parameters, with a larger budget than a diff --git a/docs/mcp.md b/docs/mcp.md index b65179035..ac4ea9cb1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -437,7 +437,7 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | +| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -5985,8 +5985,17 @@ undo step; it may change, delete or add objects, and the result is selected). A id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and `effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions -that no built-in format has. A failing plug-in returns an error and leaves the document as it was (an export writes -no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its +that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` +with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: +"plugin.", params?}` (and `open_file`) opens a file whose extension no built-in format reads, by its content or its +extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). +`importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such +as `svg`. The importer is given at most 128 MiB of the file; its objects become a new document whose artboard is +their bounds, Save then asks for a name, and `warnings` holds the importer's warnings. `plugin.run` of an importer is +an error that names `document.open`. In the app, File › Open With lists the installed importers, and +`file.openWith {importer, path?}` opens `path`, or a file picked among the importer's extensions, with one (on the web +it opens File › Open's picker). A failing plug-in returns an error and leaves the document as it was (an export writes +no file, and an import opens no document). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) @@ -6004,6 +6013,7 @@ value or the folder the preference already holds, and the web build does not che {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/plot.hpgl","importer":"plugin.org.example.hpgl"}}} ``` ## Colour adjustments diff --git a/docs/plugins.md b/docs/plugins.md index 96f8ac6e3..e92a5a11e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -5,7 +5,7 @@ arbitrary machine code and cannot work in the browser build. Instead, plug-ins a run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. -There are three kinds of plug-in: +There are four kinds of plug-in: - **Object filters** read the selected paths and compound paths (geometry, fills, strokes, gradients, opacity) and return the objects to keep, change or add. A run is one undo step. Use them for recolouring, path @@ -15,6 +15,9 @@ There are three kinds of plug-in: - **Exporters** write the whole document in a file format of their own, for pen plotters, cutting machines, laser cutters and the like. File › Export › Export As lists them after the built-in formats, and the host writes the bytes they return: a plug-in still never touches a file. +- **Importers** read files in a format of their own, such as a plotter's or a cutting machine's, into a new document of + paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host + hands them the file's bytes. This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the `plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). @@ -38,6 +41,16 @@ An exporter adds a write-only format, `plugin.`, which `document.formats` li `document.export {"format": "plugin.", "params"?: {…}, "path"?}` writes it, and so does a `path` ending in one of its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. +An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id, its extensions +in `openExtensions`. `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format +reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the +one whose id sorts first opens it. `document.open {"importer": "plugin.", "params"?: {…}}` names the importer, +which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` +only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an +importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high); +the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are +VectorCraft's own. + In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns, text, lists @@ -45,7 +58,10 @@ of numbers and colors, labelled, explained and sorted into sections as its manif parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, which keeps its heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the -same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs +same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › +Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the +installed importers by name (`file.openWith {"importer", "path"?}`), and each opens a file picked among its extensions +with that importer (on the web, File › Open's picker opens instead). **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). @@ -71,20 +87,22 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Module size | 32 MiB | rejected | | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | -| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export 10 G + 4,000 per byte | `Limit("instruction budget")` | -| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run or export (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` | +| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that | | Call depth | 1,024 frames | trap | | Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | An export runs where the export runs: with Background Export on, on the export's worker, whose Cancel stops the -plug-in between two of those 20 M-instruction slices; nothing is written then. +plug-in between two of those 20 M-instruction slices; nothing is written then. An import stops the same way when the +open it runs in is cancelled, and nothing opens then. ### Heavy filters Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its -manifest (only a filter may: a live effect runs whenever the document is drawn, and an exporter has an export's -budget). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s +manifest (only a filter may: a live effect runs whenever the document is drawn, an exporter has an export's budget and +an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s (`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how much there is only once the run has ended. @@ -115,12 +133,14 @@ capability model proposed for PhotoCraft plug-ins | `state` | filter | Keep data of its own with the document (`documentData`). | | `effect.render` | effect | Change how the objects its effect is applied to are drawn. | | `export.write` | exporter | Write the document as a file in its own format. | +| `import.read` | importer | Read files you open in its format and make documents from them. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and - `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is - empty may do none of these, and also installs without a prompt. -- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. + `export.write`; an importer `import.read`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. + A filter whose list is empty may do none of these, and also installs without a prompt. +- An effect, an exporter or an importer that lists permissions lists all of its implied ones. A filter may list any of + its three. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, or one of the names kept for a later plug-in API (`progress`, `ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. @@ -197,16 +217,17 @@ pointers are offsets into the module's memory. - `id`: 1–64 characters from `A-Z a-z 0-9 . _ -` (reverse-DNS recommended). Installing a module with the same id replaces the old one. A live effect's effect id is `plugin.`. -- `name`: the menu label, or an exporter's format name in Export As (1–64 printable characters). `kind`: `"filter"` - (object filter), `"effect"` (live effect) or `"exporter"`. Exporters came after the first ABI v1 hosts, which refuse - the kind when the plug-in is installed. +- `name`: the menu label, or an exporter's or importer's format name in Export As, Open and Open With (1–64 printable + characters). `kind`: `"filter"` (object filter), `"effect"` (live effect), `"exporter"` or `"importer"`. Exporters + and importers came after the first ABI v1 hosts, which refuse the kind when the plug-in is installed. - `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. - `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names ([permissions](#permissions)); without it, what its kind implies. -- `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot - (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): - `document.export` then writes the exporter's format only when asked for by its format id. +- `extensions` (exporters and importers only, and required): 1–8 file extensions, lower-case letters and digits + without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both + list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id, and + `document.open` reads a file with the importer only when `importer` names it. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults @@ -255,6 +276,8 @@ appearance and effect record, artboard, swatch and symbol, so an exporter reads `version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. +An **importer** gets the bytes of the file being opened, as they are (not JSON), at most 128 MiB of them. + ```json {"objects": [ {"id": 12, "type": "path", "name": "Leaf", @@ -299,14 +322,16 @@ type and `blob` but without their data, which follows the JSON in a saved file; "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792], "documentData": {"pens": 4}}} ``` -`mode` is `"filter"`, `"effect"` or `"export"`; `bounds` the selection's geometric bounds (a live effect: the reference +`mode` is `"filter"`, `"effect"`, `"export"` or `"import"`; `bounds` the selection's geometric bounds (a live effect: the reference box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", "name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the -extension (the document's name when the export is not given a path), which some formats keep in their header. +extension (the document's name when the export is not given a path), which some formats keep in their header. An +import's `_context` is `{"mode": "import", "extension": "plt", "name": "Poster"}`: the extension of the file being +opened and its name without the extension. ### Output @@ -357,6 +382,11 @@ An **exporter** returns `{"dataBase64": "…", "warnings"?: ["…"]}`: the file, is, and what the export left out or changed, for the user (at most 100 warnings of at most 500 characters each, shown with the export's own). A decline (`{"error": …}`), a missing `dataBase64` or data that isn't base64 writes nothing. +An **importer** returns `{"objects": [...], "warnings"?: ["…"]}`: new paths and compound paths (no `id`) in the +filters' object form, checked as a filter's new objects are, with warnings capped as an exporter's are and shown with +the open's own. An output with no objects, with keys other than `objects`, `warnings` and `error`, or with an object +that fails the checks opens nothing, and neither does a decline (`{"error": …}`). + ## Writing a plug-in in Rust [`crates/plugins/example`](../crates/plugins/example) is a complete object filter, **Desaturate**: it moves every @@ -386,8 +416,8 @@ per change of their input, parameters or paints (results are cached), not per fr ## Not in v1 - Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host - callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open - files). + callbacks (a plug-in reporting progress or asking whether it was cancelled), and importers that place files + (File › Place reads only the built-in formats) or make type, images, layers or artboards. - Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or symbols. - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 88a2a8707..36fca35a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1439,6 +1439,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. - **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.rs`. Scores and hours unchanged. +- **Importer plug-ins (G17):** a WebAssembly plug-in of kind `importer` adds a read-only file format, as [the help page](https://ai-sdk-2020.84.nz/html/struct_a_i_file_format_suite) describes for file format plug-ins that read: File › Open lists its files under "All readable files" and its own filter, and File › Open With lists each importer by name. `document.open` uses it for a file whose extension no built-in format reads, by content or extension (the importer whose id sorts first, when several list the extension), or the one `importer` names, also for an extension a built-in format reads; a damaged file of a built-in format reports that format's error. The plug-in is given at most 128 MiB of the file and `_context` (`mode: "import"`, `extension`, `name`), and returns paths and compound paths in the filters' object JSON, checked as a filter's output is; they become a new document whose artboard is their bounds, which Save asks a name for. A decline, invalid output, a trap or Cancel opens nothing. The routing rule, the limit and Open With are VectorCraft's own. Regressions: `crates/engine/tests/plugin_import.rs`, `crates/plugins/src/import.rs`, `crates/ui-egui/src/dialogs/tests_plugin_import.rs`. Scores and hours unchanged. - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged. From 36eec28617cf955b6a3a7e5ceafc49a1986df25e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 04:40:23 -0800 Subject: [PATCH 12/38] MCP: open_file takes an importer plug-in to open the file with open_file passes `importer` (an importer plug-in's format id, plugin.) on to app.open. Headless, it becomes document.open's `importer`; in the app, app.open with `importer` runs file.openWith, so the file opens with that importer as File > Open With does, also when a built-in format reads its extension. --- crates/mcp/src/headless.rs | 9 +++++++-- crates/mcp/src/tests/fileio.rs | 16 ++++++++++++++++ crates/mcp/src/tools/definitions.rs | 10 ++++++++-- crates/mcp/src/tools/dispatch.rs | 6 +++++- crates/ui-egui/src/control.rs | 5 ++++- .../ui-egui/src/dialogs/tests_plugin_import.rs | 5 +++++ docs/control-protocol.md | 2 +- docs/mcp.md | 4 ++-- 8 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 0a81bb127..05ea0fb4c 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -295,11 +295,16 @@ impl Headless { Ok(out) } - /// `app.open {path}`: any readable file as a new active document (`document.open`). + /// `app.open {path, importer?}`: any readable file as a new active document (`document.open`, + /// with the importer plug-in `importer` names). pub fn open(&mut self, p: &Value) -> Result { let path = s(p, "path").ok_or("missing `path`")?; + let mut params = json!({"path": path}); + if let Some(importer) = p.get("importer").filter(|v| !v.is_null()) { + params["importer"] = importer.clone(); + } let roots = self.roots.clone(); - file_access::confine(roots.as_ref(), || self.session.execute("document.open", &json!({"path": path}))).map_err(|e| e.to_string()) + file_access::confine(roots.as_ref(), || self.session.execute("document.open", ¶ms)).map_err(|e| e.to_string()) } /// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 58a6260be..6d934d48e 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -415,3 +415,19 @@ fn document_color_mode_puts_rgb_greys_on_the_black_plate() { let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}})); assert!(bad.is_error, "{bad:?}"); } + +/// `open_file`'s `importer` reaches `document.open`: an SVG opens with the SVG reader without it, +/// and naming an importer that isn't installed is an error that names it. +#[test] +fn open_file_passes_the_importer_on() { + let tools = tool_definitions(); + let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); + assert!(open["inputSchema"]["properties"].get("importer").is_some(), "{open}"); + let svg = tmp("importer.svg"); + std::fs::write(&svg, r#""#).unwrap(); + let mut h = Headless::new(); + assert_eq!(text(&call_tool(&mut h, "open_file", &json!({"path": svg})))["format"], "svg"); + let r = call_tool(&mut h, "open_file", &json!({"path": svg, "importer": "plugin.test.mcp.absent"})); + assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("test.mcp.absent"), "{r:?}"); + assert_eq!(h.session.documents().len(), 1); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 105dca346..5af8a0c27 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -357,10 +357,16 @@ pub fn tool_definitions() -> Vec { "open_file", "Open file", &format!( - "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read).", + "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read). A file of an extension no built-in format reads opens with the installed importer plug-in that lists it; `importer` names the importer to use (File › Open With), also for an extension a built-in format reads.", OPEN_EXTS.join(", .") ), - obj(json!({"path": string("File path")}), &["path"]), + obj( + json!({ + "path": string("File path"), + "importer": string("An importer plug-in's format id, plugin. (see run_command document.formats), which must list the file's extension (default: the built-in format that reads the file, else the first importer by id that lists its extension)"), + }), + &["path"], + ), false, ), tool( diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index 57167439f..cab82b3b6 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -191,7 +191,11 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result screenshot(b, a), "open_file" => { let path = absolute_path(b, req_str(a, "path")?)?; - j(b.call("app.open", json!({"path": path}))?) + let mut p = json!({"path": path}); + if let Some(importer) = a.get("importer").filter(|v| !v.is_null()) { + p["importer"] = importer.clone(); + } + j(b.call("app.open", p)?) } "save_file" => { let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?}); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fccfbedab..a7f154b52 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -443,7 +443,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques None => ok(with_data(json!({"width": img.width, "height": img.height}), Some(&png))), } } - "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), + "app.open" => match p.get("importer").filter(|v| !v.is_null()) { + Some(importer) => wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer}))), + None => wrap(app.run("file.open", json!({"path": s("path")}))), + }, "app.save" | "app.export" => { let mut p = if p.is_object() { p.clone() } else { json!({}) }; // `wait: true`: the file is written before the reply, whatever Background Save and diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index 7d2e7b9a1..9b3a45b70 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -68,6 +68,11 @@ fn open_with_lists_importers_and_opens_a_file_with_one() { assert_eq!(r["format"], "plugin.test.ui.svgreader"); assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0), "Open With: the importer"); assert_eq!(app.session.documents().len(), 2); + // `app.open {importer}` (MCP's open_file) opens as Open With does. + let (req, _rx) = crate::control::ControlRequest::new("app.open", json!({"path": svg, "importer": "plugin.test.ui.svgreader"})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") }; + assert_eq!(r["result"]["format"], "plugin.test.ui.svgreader", "{r}"); + assert_eq!(app.session.documents().len(), 3); // An importer is not a filter: Object › Plug-ins' command says where it is. let e = app.run("plugin.dialog", json!({"id": "test.ui.svgreader"})).unwrap_err(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4e8408cd5..1d8d4f699 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | diff --git a/docs/mcp.md b/docs/mcp.md index ce1b5885e..5c3aaabe9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -443,7 +443,7 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -6092,7 +6092,7 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: -"plugin.", params?}` (and `open_file`) opens a file whose extension no built-in format reads, by its content or its +"plugin.", params?}` (and `open_file {path, importer?}`) opens a file whose extension no built-in format reads, by its content or its extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). `importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such as `svg`. The importer is given at most 128 MiB of the file; its objects become a new document whose artboard is From 58f5789e9ab17891029948b07e6715812f0d06b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:07:08 -0800 Subject: [PATCH 13/38] Plug-ins: Open With passes an importer its parameters and adds the file to Open Recent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open With asks for the parameters of an importer that declares any, at their defaults, once the file is picked (dialog `pluginImport`), and opens the file with the values set. `file.openWith`, `app.open` and MCP's `open_file` take `params` and pass them to the importer; `app.open` without them runs the importer on their defaults, with no dialog. A file opened with Open With is added to Open Recent, as File › Open adds the files it opens. --- crates/mcp/src/headless.rs | 10 ++-- crates/mcp/src/tests/fileio.rs | 27 ++++++++++ crates/mcp/src/tools/definitions.rs | 3 +- crates/mcp/src/tools/dispatch.rs | 6 ++- crates/ui-egui/src/control.rs | 6 ++- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/dialogs/plugin.rs | 32 ++++++++++- .../src/dialogs/tests_plugin_import.rs | 54 ++++++++++++++++++- crates/ui-egui/src/io/open.rs | 30 +++++++---- docs/control-protocol.md | 6 ++- docs/mcp.md | 10 ++-- docs/plugins.md | 6 ++- 12 files changed, 164 insertions(+), 27 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 05ea0fb4c..8633fd568 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -295,13 +295,15 @@ impl Headless { Ok(out) } - /// `app.open {path, importer?}`: any readable file as a new active document (`document.open`, - /// with the importer plug-in `importer` names). + /// `app.open {path, importer?, params?}`: any readable file as a new active document + /// (`document.open`, with the importer plug-in `importer` names and its parameters `params`). pub fn open(&mut self, p: &Value) -> Result { let path = s(p, "path").ok_or("missing `path`")?; let mut params = json!({"path": path}); - if let Some(importer) = p.get("importer").filter(|v| !v.is_null()) { - params["importer"] = importer.clone(); + for k in ["importer", "params"] { + if let Some(v) = p.get(k).filter(|v| !v.is_null()) { + params[k] = v.clone(); + } } let roots = self.roots.clone(); file_access::confine(roots.as_ref(), || self.session.execute("document.open", ¶ms)).map_err(|e| e.to_string()) diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 6d934d48e..00ff3a2d7 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -431,3 +431,30 @@ fn open_file_passes_the_importer_on() { assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("test.mcp.absent"), "{r:?}"); assert_eq!(h.session.documents().len(), 1); } + +/// `open_file` sends `app.open` the importer's `params` with the importer. +#[test] +fn open_file_passes_the_importer_s_params_on() { + struct Recording(Vec<(String, Value)>); + impl Backend for Recording { + fn call(&mut self, method: &str, params: Value) -> Result { + self.0.push((method.to_string(), params)); + Ok(json!({})) + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "recording".into() + } + } + let tools = tool_definitions(); + let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); + assert_eq!(open["inputSchema"]["properties"]["params"]["type"], "object", "{open}"); + let mut b = Recording(vec![]); + let args = json!({"path": "/in/a.tup", "importer": "plugin.test.mcp.params", "params": {"density": 7}}); + assert!(!call_tool(&mut b, "open_file", &args).is_error); + let [(method, params)] = &b.0[..] else { panic!("one call: {:?}", b.0) }; + assert_eq!(method, "app.open"); + assert_eq!(params, &args); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 5af8a0c27..616e32aae 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -357,13 +357,14 @@ pub fn tool_definitions() -> Vec { "open_file", "Open file", &format!( - "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read). A file of an extension no built-in format reads opens with the installed importer plug-in that lists it; `importer` names the importer to use (File › Open With), also for an extension a built-in format reads.", + "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read). A file of an extension no built-in format reads opens with the installed importer plug-in that lists it; `importer` names the importer to use (File › Open With), also for an extension a built-in format reads, and `params` gives that importer's parameters.", OPEN_EXTS.join(", .") ), obj( json!({ "path": string("File path"), "importer": string("An importer plug-in's format id, plugin. (see run_command document.formats), which must list the file's extension (default: the built-in format that reads the file, else the first importer by id that lists its extension)"), + "params": {"type": "object", "description": "The importer plug-in's parameters (see run_command plugin.list; default: the defaults its manifest declares)"}, }), &["path"], ), diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index cab82b3b6..eabd73e3e 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -192,8 +192,10 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { let path = absolute_path(b, req_str(a, "path")?)?; let mut p = json!({"path": path}); - if let Some(importer) = a.get("importer").filter(|v| !v.is_null()) { - p["importer"] = importer.clone(); + for k in ["importer", "params"] { + if let Some(v) = a.get(k).filter(|v| !v.is_null()) { + p[k] = v.clone(); + } } j(b.call("app.open", p)?) } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a7f154b52..203fd46a3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -444,7 +444,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } } "app.open" => match p.get("importer").filter(|v| !v.is_null()) { - Some(importer) => wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer}))), + // The importer's parameters, else their defaults: automation gets no dialog asking for them. + Some(importer) => { + let params = p.get("params").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!({})); + wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer, "params": params}))) + } None => wrap(app.run("file.open", json!({"path": s("path")}))), }, "app.save" | "app.export" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1e301a7ca..bc4616acf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -390,6 +390,7 @@ registry! { PrintPresets: [print_presets::KIND] => print_presets::SPEC, Plugin: [plugin::KIND] => plugin::SPEC, PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC, + PluginImport: [plugin::IMPORT_KIND] => plugin::IMPORT_SPEC, ScriptCheck: [script_check::KIND] => script_check::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 8096f1e58..a5736c47e 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -9,7 +9,8 @@ //! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its //! Export button writes the file. Importers open the files File › Open finds no built-in format -//! for, and File › Open With lists them, to open a file with one of them by name. +//! for, and File › Open With lists them, to open a file with one of them by name; one with +//! parameters opens dialog `pluginImport` once the file is picked, and its Open button opens it. use std::sync::Arc; @@ -211,3 +212,32 @@ fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result app.ui.dialog = None; crate::io::export(app, format.as_deref(), path, &p) } + +/// The dialog kind of an importer's parameters, after Open With picked the file. +pub const IMPORT_KIND: &str = "pluginImport"; + +/// Headed by the plug-in's name, as it is; Open opens the file. +pub(super) const IMPORT_SPEC: DialogSpec = DialogSpec { + heading: |d| d.str("__label"), + body: export_body, + confirm: import_confirm, + ok: Some("Open"), + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// Open With with importer `plugin`, which has parameters: its fields at their defaults, and +/// `open`, Open With's own parameters (`path`, `importer`), kept for Open. +pub fn open_import(app: &mut VectorcraftApp, plugin: &Plugin, open: Value) { + let mut fields = fields_for(plugin.manifest()); + fields.insert("__open".into(), open); + app.ui.dialog = Some(Dialog { kind: IMPORT_KIND.into(), fields }); +} + +/// Opens the file with Open With's parameters and the plug-in's, as `params`. +fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = d.fields.get("__open").cloned().filter(Value::is_object).ok_or("no file to open")?; + p["params"] = form::params(d); + app.ui.dialog = None; + crate::io::open_with(app, &p) +} diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index 9b3a45b70..a8ce15ded 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -6,8 +6,9 @@ use serde_json::json; use vectorcraft_engine::Session; -use vectorcraft_plugins::wat as tpl; +use vectorcraft_plugins::wat::{self as tpl, Given}; +use super::tests_export::{frame, kind, set}; use super::*; use crate::Services; use crate::menus::Item; @@ -64,10 +65,12 @@ fn open_with_lists_importers_and_opens_a_file_with_one() { let svg = svg.to_string_lossy().into_owned(); crate::io::open_path(&mut app, &svg).unwrap(); assert_eq!(artboard(&app), (0.0, 0.0, 50.0, 40.0), "File › Open: the built-in SVG reader"); + app.ui.recent_files.clear(); let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.svgreader", "path": svg})).unwrap(); assert_eq!(r["format"], "plugin.test.ui.svgreader"); assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0), "Open With: the importer"); assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&svg), "Open With adds the file to Open Recent"); // `app.open {importer}` (MCP's open_file) opens as Open With does. let (req, _rx) = crate::control::ControlRequest::new("app.open", json!({"path": svg, "importer": "plugin.test.ui.svgreader"})); let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") }; @@ -83,6 +86,55 @@ fn open_with_lists_importers_and_opens_a_file_with_one() { let _ = std::fs::remove_dir_all(&dir); } +/// Open With asks for an importer's parameters, at their defaults, once the file is picked, and +/// opens the file with the values set; given `params`, it asks nothing, and `app.open` without +/// them runs the importer on their defaults. +#[test] +fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { + let dir = tmp_dir("params"); + let mut app = app(); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + // RECT when the parameters hold density 7; the file, which isn't JSON, otherwise. + install(&mut app, &tpl::when_given(&tpl::importer("test.ui.params", r#"["tup"]"#, params), Given::Params, r#""density":7.0"#, RECT)); + let file = dir.join("a.tup"); + std::fs::write(&file, "not json").unwrap(); + let file = file.to_string_lossy().into_owned(); + + app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file})).unwrap(); + assert_eq!(kind(&app), Some(plugin::IMPORT_KIND)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.params".to_string(), Some(&json!(4.0)))); + assert!(app.session.documents().is_empty(), "nothing opened before Open"); + frame(&mut app); + set(&mut app, "density", json!(7)); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0)); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&file)); + + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file, "params": {"density": 7}})).unwrap(); + assert_eq!(r["format"], "plugin.test.ui.params"); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.documents().len(), 2); + + // `app.open` (MCP's open_file) passes `params` on, and asks nothing without them. + let control = |app: &mut VectorcraftApp, p: serde_json::Value| { + let (req, _rx) = crate::control::ControlRequest::new("app.open", p); + let crate::control::Outcome::Done(r) = crate::control::handle(app, &egui::Context::default(), &req) else { panic!("not done") }; + r + }; + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.params", "params": {"density": 7}})); + assert_eq!(r["result"]["format"], "plugin.test.ui.params", "{r}"); + assert_eq!(app.session.documents().len(), 3); + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.params"})); + assert!(r.get("error").is_some(), "the defaults (density 4) don't open the file: {r}"); + assert_ne!(kind(&app), Some(plugin::IMPORT_KIND)); + assert_eq!(app.session.documents().len(), 3); + app.run("plugin.remove", json!({"id": "test.ui.params"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + /// File › Open reads a file of an extension only an importer lists, and refuses one past the /// importer's input limit before reading it, as Open With does. #[test] diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index bb605de7b..94b41ea7d 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -260,9 +260,11 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { /// File › Open With: opens the file `p` names (`path`), else one picked among the files with the /// extensions the importer plug-in `importer` lists, with that importer, also when a built-in -/// format reads the extension (`document.open {importer}`); a failure is reported as File › Open -/// reports it. The web's file picker can't carry the importer: there File › Open's picker opens, -/// and the file opens as File › Open opens it. +/// format reads the extension (`document.open {importer, params}`), and adds it to Open Recent; a +/// failure is reported as File › Open reports it. An importer with parameters, given no `params`, +/// asks for them first in dialog `pluginImport`, at their defaults. The web's file picker can't +/// carry the importer: there File › Open's picker opens, and the file opens as File › Open opens +/// it. pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { let importer = p.get("importer").and_then(Value::as_str).ok_or("missing `importer` (see document.formats)")?; let plugin = vectorcraft_plugins::import::named(importer).map_err(|e| e.to_string())?; @@ -279,12 +281,22 @@ pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { pick_open(app, &FilePick { filters, folder: open_dialog_folder(app), ..Default::default() })? } }; - let opened = vectorcraft_engine::file_access::check_read(&path) - .and_then(|()| check_import_size(&path, true)) - .and_then(|()| read(app, &path)) - .and_then(|bytes| { - open_document(app, &path, &bytes, Some(path.clone()), &json!({ "importer": vectorcraft_plugins::export::format_id(plugin.id()) })) - }); + let importer = vectorcraft_plugins::export::format_id(plugin.id()); + let given = p.get("params").filter(|v| !v.is_null()); + let opened = vectorcraft_engine::file_access::check_read(&path).and_then(|()| check_import_size(&path, true)).and_then(|()| { + if given.is_none() && !plugin.manifest().params.is_empty() { + dialogs::plugin::open_import(app, &plugin, json!({ "path": path, "importer": importer })); + return Ok(Value::Null); + } + let mut o = json!({ "importer": importer }); + if let Some(params) = given { + o["params"] = params.clone(); + } + let bytes = read(app, &path)?; + let r = open_document(app, &path, &bytes, Some(path.clone()), &o)?; + note_recent(app, &path); + Ok(r) + }); if let Err(e) = &opened { report_open_error(app, &fileio::file_name(&path), e); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1d8d4f699..ce944a78b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | @@ -213,7 +213,9 @@ after it), `__plugin` (the plug-in id) and `preview`. It previews `plugin.run` o `ui.dialog.confirm` keeps the result as one undo step and `ui.dialog.cancel` rolls it back. Live-effect plug-ins open the `effect` dialog (`effect.dialog {effect: "plugin."}`). An exporter plug-in with parameters opens the `pluginExport` dialog once Export As has picked the file: one field per parameter, `__plugin`, and `__export` (the -export's own parameters); `ui.dialog.confirm` writes the file. `ui.installPlugin {path?}` installs a `.wasm` (without +export's own parameters); `ui.dialog.confirm` writes the file. An importer plug-in with parameters opens the +`pluginImport` dialog when `file.openWith` is given no `params`: one field per parameter, `__plugin`, and `__open` +(`path` and `importer`); `ui.dialog.confirm` opens the file. `ui.installPlugin {path?}` installs a `.wasm` (without a path it asks for one); see [plugins.md](plugins.md). Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for diff --git a/docs/mcp.md b/docs/mcp.md index 5c3aaabe9..8a5e83f18 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -443,7 +443,7 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path, importer?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -6092,14 +6092,16 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: -"plugin.", params?}` (and `open_file {path, importer?}`) opens a file whose extension no built-in format reads, by its content or its +"plugin.", params?}` (and `open_file {path, importer?, params?}`) opens a file whose extension no built-in format reads, by its content or its extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). `importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such as `svg`. The importer is given at most 128 MiB of the file; its objects become a new document whose artboard is their bounds, Save then asks for a name, and `warnings` holds the importer's warnings. `plugin.run` of an importer is an error that names `document.open`. In the app, File › Open With lists the installed importers, and -`file.openWith {importer, path?}` opens `path`, or a file picked among the importer's extensions, with one (on the web -it opens File › Open's picker). A failing plug-in returns an error and leaves the document as it was (an export writes +`file.openWith {importer, path?, params?}` opens `path`, or a file picked among the importer's extensions, with one (on +the web it opens File › Open's picker) and adds it to Open Recent; an importer with parameters, given no `params`, +first asks for them in the `pluginImport` dialog. `params` holds the importer's parameters (their defaults when +omitted). A failing plug-in returns an error and leaves the document as it was (an export writes no file, and an import opens no document). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. diff --git a/docs/plugins.md b/docs/plugins.md index 4b29b5ef2..c9d40853f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -60,8 +60,10 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the -installed importers by name (`file.openWith {"importer", "path"?}`), and each opens a file picked among its extensions -with that importer (on the web, File › Open's picker opens instead). **File › Open** of a `.wasm` file installs +installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its +extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. An importer +with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open +button opens the file; File › Open runs an importer with its parameters' defaults. **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). From d65207dc620de7c9474905524257569c4eb99a67 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:35:23 -0800 Subject: [PATCH 14/38] =?UTF-8?q?Plug-ins:=20only=20File=20=E2=80=BA=20Ope?= =?UTF-8?q?n=20lists=20importers'=20files,=20and=20imported=20art=20at=20t?= =?UTF-8?q?he=20canvas=20edge=20keeps=20an=20editable=20artboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New from Template, Load Character/Paragraph/All Styles, Recolor's Other Library… and the library panels' Other Library… read files with the built-in readers, so their open dialogs now use builtin_open_filters(), which leaves out the extensions only importer plug-ins read. File › Open keeps open_filters() with the importers' files. The artboard of a document an importer opens is now kept within ±4,000,000 pt. Art at the edge (a zero-width path at x = 4,000,000, or a stroke past the edge) gave an artboard beyond it, and every later edit of the document was rejected. --- crates/engine/src/cmd/fileio.rs | 6 ++-- crates/engine/src/cmd/fileio/formats.rs | 22 +++++++++++--- crates/engine/src/cmd/fileio/importer.rs | 21 ++++++++++---- crates/engine/tests/plugin_import.rs | 15 ++++++++++ crates/ui-egui/src/dialogs/recolor.rs | 4 +-- .../src/dialogs/tests_plugin_import.rs | 29 +++++++++++++++++++ crates/ui-egui/src/io/open.rs | 2 +- crates/ui-egui/src/panels/library_panel.rs | 2 +- crates/ui-egui/src/panels/text_styles.rs | 2 +- docs/plugins.md | 7 +++-- 10 files changed, 89 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 9903ba4cb..52d54ce19 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -130,9 +130,9 @@ pub(crate) use export_names::{default_name, merge, unique_file_names, with_black use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ - DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, - file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, open_filters, place_filters, - plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, + DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, + export_extensions, extension, file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, + open_filters, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 49f8af812..40acda270 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -693,7 +693,7 @@ pub fn open_extensions() -> &'static [&'static str] { exts } -/// Open-dialog filters: "All readable files" first, then one per readable format, then one per +/// File › Open's filters: "All readable files" first, then one per readable format, then one per /// importer plug-in (under its name), then swatch libraries (which open in the library panel), /// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked /// without running them) and plug-ins (installed). @@ -701,7 +701,20 @@ pub fn open_filters() -> impl Iterator impl Iterator { + std::iter::once(("All readable files", OPEN_EXTS)).chain(format_filters()).chain(other_open_filters()) +} + +/// The open-dialog filters after the document formats: swatch libraries, Libraries panel +/// libraries, presets, shortcut sets, scripts and plug-ins. +fn other_open_filters() -> impl Iterator { + std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS)) .chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS))) .chain(std::iter::once(("PDF presets", crate::cmd::pdfcmds::PRESET_EXTS))) @@ -715,12 +728,13 @@ pub fn open_filters() -> impl Iterator impl Iterator { - std::iter::once((library_label, library_exts)).chain(open_filters()) + std::iter::once((library_label, library_exts)).chain(builtin_open_filters()) } /// File → Place dialog filters: "All placeable files", then one per placeable format, then text, diff --git a/crates/engine/src/cmd/fileio/importer.rs b/crates/engine/src/cmd/fileio/importer.rs index 0a341c079..9fc667c4a 100644 --- a/crates/engine/src/cmd/fileio/importer.rs +++ b/crates/engine/src/cmd/fileio/importer.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::Document; +use vectorcraft_doc::{Document, MAX_COORD}; use vectorcraft_geom::Rect; use vectorcraft_plugins::{Plugin, export, import, objects}; @@ -101,15 +101,24 @@ pub(super) fn load(name: &str, bytes: &[u8], plugin: &Plugin, p: &Value) -> Resu let ids = objects::apply_output(&mut doc, &[], imported.objects, layer, &admit).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; if let (Some(bounds), Some(board)) = (doc.bounds_of(&ids, true), doc.artboards.first_mut()) { - board.rect = at_least_a_point(bounds); + board.rect = artboard_rect(bounds); } doc.title = file_name(name); Ok(Loaded { doc, format, warnings: imported.warnings, restored: false, converted: false, preview_only: false, editing_data: false }) } -/// `r` at least 1 pt wide and high, about its center: the artboard of a single line or point. -fn at_least_a_point(r: Rect) -> Rect { - let grow = |lo: f64, hi: f64| if hi - lo >= 1.0 { (lo, hi) } else { ((lo + hi) / 2.0 - 0.5, (lo + hi) / 2.0 + 0.5) }; - let ((x0, x1), (y0, y1)) = (grow(r.x0, r.x1), grow(r.y0, r.y1)); +/// The artboard of art with bounds `r`: `r` within ±[`MAX_COORD`], at least 1 pt wide and high +/// about its center (the artboard of a single line or point). A side that would pass the edge +/// moves inward, so art at the edge keeps an artboard the document's edits accept. +fn artboard_rect(r: Rect) -> Rect { + let side = |lo: f64, hi: f64| { + let (lo, hi) = (lo.max(-MAX_COORD), hi.min(MAX_COORD)); + if hi - lo >= 1.0 { + return (lo, hi); + } + let mid = ((lo + hi) / 2.0).clamp(-MAX_COORD + 0.5, MAX_COORD - 0.5); + (mid - 0.5, mid + 0.5) + }; + let ((x0, x1), (y0, y1)) = (side(r.x0, r.x1), side(r.y0, r.y1)); Rect::new(x0, y0, x1, y1) } diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index f56684172..f5cfea80b 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -109,6 +109,21 @@ fn a_file_an_importer_reads_opens_as_editable_art() { open(&mut s, "Line.tso", LINE.as_bytes(), json!({})).unwrap(); assert_eq!(artboard(&s), (0.0, 4.5, 50.0, 5.5)); + // Art at the edge of the canvas keeps its artboard within ±4,000,000 pt, so the document can + // still be edited: a vertical line at x = 4,000,000 (its 1 pt moves inward) and a stroked line + // along y = -4,000,000 (its stroke passes the edge). + let edge = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[4000000,0]},{"p":[4000000,50]}]}]}}]}"#; + open(&mut s, "Edge.tso", edge.as_bytes(), json!({})).unwrap(); + assert_eq!(artboard(&s), (3_999_999.0, 0.0, 4_000_000.0, 50.0)); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap(); + let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#; + open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap(); + let (x0, y0, x1, y1) = artboard(&s); + assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap(); + // From a path: the document keeps no path, so Save asks for a name. let path = dir.join("Shapes.tso"); std::fs::write(&path, RECT).unwrap(); diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 5efa3ef6e..6f696f430 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -604,8 +604,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut // Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it. if app.services.pick_open.is_some() && widgets::icon_button(ui, "folder-open", tl!("Other Library…"), false, 24.0).clicked() { let r = crate::picks::in_dialog(app, d, |app, d| { - let pick = - crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::open_filters()), ..Default::default() }; + let filters = crate::FilePick::filters(vectorcraft_engine::cmd::fileio::builtin_open_filters()); + let pick = crate::FilePick { filters, ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) }; let r = app.run("swatch.library.load", json!({ "path": path }))?; d.fields.insert("limitTo".into(), r["library"].clone()); diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index a8ce15ded..eab73422e 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -135,6 +135,35 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { let _ = std::fs::remove_dir_all(&dir); } +/// File › Open's dialog lists an importer's files; the open dialogs whose files only the built-in +/// readers read (New from Template, Load Styles, the library panels' Other Library…) don't. +#[test] +fn only_file_open_s_dialog_lists_an_importer_s_files() { + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.filters", r#"["tuf"]"#, "{}"), tpl::ECHO, "", 1)); + let asked: std::rc::Rc>> = Default::default(); + let seen = asked.clone(); + app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| { + seen.borrow_mut().push(pick.clone()); + None + })); + let lists = |filters: &[(String, Vec)]| filters.iter().any(|(_, exts)| exts.iter().any(|e| e == "tuf")); + let shown = |app: &mut VectorcraftApp, cmd: &str| { + let _ = app.run(cmd, json!({})); + let pick = asked.borrow_mut().pop().unwrap_or_else(|| panic!("{cmd} showed no open dialog")); + lists(&pick.filters) + }; + assert!(shown(&mut app, "file.open"), "File › Open"); + for cmd in ["file.newFromTemplate", "ui.loadCharStyles", "ui.loadParaStyles", "ui.loadAllStyles"] { + assert!(!shown(&mut app, cmd), "{cmd}"); + } + for kind in ["swatches", "graphicStyles", "symbols"] { + let filters = crate::FilePick::filters(crate::panels::library_panel::library_pick_filters(kind)); + assert!(!lists(&filters), "{kind}"); + } + app.run("plugin.remove", json!({"id": "test.ui.filters"})).unwrap(); +} + /// File › Open reads a file of an extension only an importer lists, and refuses one past the /// importer's input limit before reading it, as Open With does. #[test] diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 94b41ea7d..fc0aa8481 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -461,7 +461,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Resu Some(p) => p, None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), None => { - let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); + let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::builtin_open_filters())); pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? } }; diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 4607edbf9..f7ee6020a 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -573,7 +573,7 @@ pub(crate) fn library_pick_filters(kind: &str) -> Vec<(&'static str, &'static [& match kind { super::swatches::SwatchLibraries::KIND => fileio::library_open_filters("Swatch libraries", swatchlib::LIBRARY_EXTS).collect(), super::graphic_styles::GraphicStyleLibraries::KIND => fileio::library_open_filters("Graphic Style Libraries", &[STYLES_EXT]).collect(), - _ => fileio::open_filters().collect(), + _ => fileio::builtin_open_filters().collect(), } } diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index eb2908400..c0a260fc9 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -421,7 +421,7 @@ pub(crate) fn load(app: &mut VectorcraftApp, load: &str, path: Option) - let path = match path { Some(path) => path, None => { - let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::open_filters()), ..Default::default() }; + let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::builtin_open_filters()), ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return Ok(Value::Null) }; path } diff --git a/docs/plugins.md b/docs/plugins.md index c9d40853f..7329488df 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -47,8 +47,8 @@ reads the file, by its content or by its extension, and the importer lists the f one whose id sorts first opens it. `document.open {"importer": "plugin.", "params"?: {…}}` names the importer, which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an -importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high); -the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are +importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high, +and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are VectorCraft's own. In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload @@ -59,7 +59,8 @@ parameters it runs at once. Parameters too many for the window's height scroll i heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › -Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the +Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New +from Template and Load Styles, list only the files the built-in formats read); **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. An importer with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open From f384d27beeec2fbe4c04b10ceab76819d5cbf7ea Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 06:35:38 -0800 Subject: [PATCH 15/38] 3D (Classic): the solids test checks the classic faces' hue every_solid_draws_shaded_faces_and_leaves_the_source_editable no longer skips the color check for Extrude & Bevel (Classic). Its faces must be the fill's red or the stroke's black, shaded, or lightened toward white by the Plastic highlight, which keeps r - g to r - b at 6 to 7. --- crates/effects/src/solids/tests.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 44204f83d..461f5610d 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -45,13 +45,15 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() { assert_eq!(n.path_data(), Some(&source), "{id}"); assert!(!faces(&art).is_empty(), "{id}"); // The faces are the fill's red or the stroke's black, shaded: never brighter, never another - // hue (the classic Plastic surface adds a white highlight, tested on its own). + // hue. The classic Plastic surface, its default, also lightens a face toward white, which + // scales every channel's distance from white by the same factor and so keeps r - g to + // r - b at 6 to 7. for f in faces(&art) { let [r, g, b] = fill_rgb(f); - assert!( - r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE || id == CLASSIC_EXTRUDE, - "{id}: {r} {g} {b}" - ); + let shaded = r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4; + let highlighted = + id == CLASSIC_EXTRUDE && [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) && ((r - g) * 7.0 - (r - b) * 6.0).abs() < 1e-4; + assert!(shaded || highlighted || id == ROTATE, "{id}: {r} {g} {b}"); } // The art follows the source when it moves. let before = art.visual_bounds().unwrap(); From 517e0b7ae85d36e26f045ec066cddcc53f234eaa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 06:35:39 -0800 Subject: [PATCH 16/38] Effects: hiding a 3D effect still checks the values given with it effect.setParams with visible: false skipped every 3D parameter check, so a hidden edit could store a Classic depth of 2001, an unknown surface or bevel, an empty light list or a Revolve edge other than left or right. The values given are now checked by validate_three_d_params (the checks of validate_three_d that do not build the art); after a change of 3D type the values the effect keeps are checked too. The object still does not need to fit while the effect is hidden. --- crates/effects/src/lib.rs | 3 +- crates/effects/src/revolve.rs | 10 ++++- crates/effects/src/solids.rs | 20 ++++++++-- crates/engine/src/cmd/effectcmd.rs | 12 ++++-- crates/engine/src/cmd/effectcmd/tests.rs | 50 ++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 417292b52..bf9fba23d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,7 +85,8 @@ pub use reshape::{ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, THREE_D_TYPES, classic_extrude_options, classic_shading, - extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_params_as, validate_solid, validate_three_d, view_options, + extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_params_as, validate_solid, validate_three_d, validate_three_d_params, + view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b2528818b..9e7179941 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -66,19 +66,25 @@ pub fn revolve_options(p: &Value) -> Revolve { /// Validate before a command mutates the document. Loading external files is still bounded /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { - crate::lighting::validate(p)?; + validate_revolve_params(p)?; let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { return Err("Revolve requires at least two anchors".into()); } + vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) +} + +/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`. +pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { + crate::lighting::validate(p)?; if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { return Err("Revolve edge must be left or right".into()); } if p.get("cap").is_some_and(|v| v.as_bool().is_none()) { return Err("Revolve cap must be a boolean".into()); } - vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) + Ok(()) } fn profile(n: &Node) -> Option { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 818fd6c1b..c31dbe856 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -182,19 +182,26 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> } } -/// Validate before a command changes the document; the evaluator bounds loaded files too. -pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { +/// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not +/// need the object. +fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { if id == CLASSIC_EXTRUDE { classic::validate(p)?; } crate::lighting::validate(p)?; - let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect(); return Err(format!("bevel must be none or one of {}", names.join(", "))); } + Ok(()) +} + +/// Validate before a command changes the document; the evaluator bounds loaded files too. +pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_solid_params(id, p)?; + let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; @@ -277,6 +284,13 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) } } +/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not +/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does +/// not need to fit. +pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> { + if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) } +} + /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and /// blend mode (so overlapping facets don't add up their transparency). fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 450dbe0e5..2b90dc6b4 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -487,7 +487,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { /// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and /// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one /// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still -/// fit. → `{ids}`. +/// fit; a hidden one has its given values checked. → `{ids}`. fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; @@ -539,7 +539,6 @@ fn set_params(s: &mut Session, p: &Value) -> Result { } if let Some(e) = effect && effects::is_three_d(&e.id) - && visible != Some(false) { let to = kind.unwrap_or(&e.id); let mut prospective = @@ -547,7 +546,14 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, ¶ms)) { cur.extend(new); } - effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; + if visible != Some(false) { + effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; + } else { + // Hidden, the effect draws nothing, so its object need not fit. The values given are + // checked, and after a change of 3D type so is every value it keeps. + let checked = if to == e.id { effects::upgrade_params(to, ¶ms) } else { prospective }; + effects::validate_three_d_params(to, &checked).map_err(|e| bad(C, e))?; + } } } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| { diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index be1dd443c..e3fca957b 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -46,6 +46,56 @@ fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { assert_eq!(effects_for_test(&s, id), before); } +/// `effect.setParams` checks the values it is given for a 3D effect when the same edit hides it: +/// a bad value is refused and the document is left as it was, and good values are stored. +#[test] +fn hiding_a_3d_effect_still_checks_its_new_values() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = |s: &mut Session, x: u32| { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap()) + }; + let classic = rect(&mut s, 20); + s.execute("effect.apply", &json!({"effect": effects::CLASSIC_EXTRUDE, "ids": [classic.0], "item": null})).unwrap(); + let extrude = rect(&mut s, 120); + s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [extrude.0], "item": null})).unwrap(); + let profile = NodeId(s.execute("path.create", &json!({"d": "M 300 40 L 330 60 L 330 140 L 300 160"})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": effects::REVOLVE, "ids": [profile.0], "item": null})).unwrap(); + for (id, params, why) in [ + (classic, json!({"depth": 2001}), "`depth` must be between 0 and 2000"), + (classic, json!({"surface": "glossy"}), "`surface` must be one of"), + (classic, json!({"bevel": "wavy"}), "bevel must be none or one of"), + (extrude, json!({"lights": []}), "lights must contain one to ten lights"), + (extrude, json!({"lightColor": "red"}), "lightColor must be an opaque RGB color"), + (profile, json!({"edge": "top"}), "Revolve edge must be left or right"), + ] { + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let p = json!({"ids": [id.0], "index": 0, "item": null, "params": params, "visible": false}); + let e = s.execute("effect.setParams", &p).unwrap_err().to_string(); + assert!(e.contains(why), "{params}: {e}"); + assert_eq!(s.doc().unwrap().doc, before, "{params}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{params}"); + } + // A hidden switch of 3D type checks the values given with it for the new type. + let before = s.doc().unwrap().doc.clone(); + let p = json!({"ids": [extrude.0], "index": 0, "item": null, "threeDType": "inflate", "params": {"lights": []}, "visible": false}); + let e = s.execute("effect.setParams", &p).unwrap_err().to_string(); + assert!(e.contains("lights must contain one to ten lights"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + // Hiding alone checks nothing. + s.execute("effect.setParams", &json!({"ids": [extrude.0], "index": 0, "item": null, "visible": false})).unwrap(); + assert!(!node(&s, extrude).appearance.effects[0].visible); + s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 2000}, "visible": false})).unwrap(); + let e = &node(&s, classic).appearance.effects[0]; + assert_eq!((e.params["depth"].as_f64(), e.visible), (Some(2000.0), false)); + // A value the effect already holds is not checked again: a key it does not take, as a file + // can carry, does not stop a hidden edit of another value. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(classic).unwrap().appearance.effects[0].params["extra"] = json!(1); + s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 10}, "visible": false})).unwrap(); + assert_eq!(node(&s, classic).appearance.effects[0].params["depth"].as_f64(), Some(10.0)); +} + /// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited, /// exported, expanded and undone; one 3D effect per object. #[test] From edd6acd9c0d068b7e094fc7839a7cfb263dbb8a3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 07:31:40 -0800 Subject: [PATCH 17/38] =?UTF-8?q?Plug-ins:=20File=20=E2=80=BA=20Open=20lis?= =?UTF-8?q?ts=20only=20the=20importer=20extensions=20it=20opens=20with=20a?= =?UTF-8?q?n=20importer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An importer may declare an extension that File › Open handles another way: a plug-in, shortcut set, script, preset, library or swatch library such as .ase, or one a built-in format reads. "All readable files" and the importer's own filter no longer list those extensions, and an importer with none left gets no filter. Open With still opens them. --- crates/engine/src/cmd/fileio.rs | 3 +- crates/engine/src/cmd/fileio/formats.rs | 84 ++++++++++++++----- crates/engine/tests/plugin_import.rs | 21 +++++ .../src/dialogs/tests_plugin_import.rs | 41 +++++++++ docs/mcp.md | 2 +- docs/plugins.md | 9 +- 6 files changed, 135 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 52d54ce19..4fda8e1ca 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -132,7 +132,8 @@ use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, export_extensions, extension, file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, - open_filters, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, + open_filters, opens_as_other, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, + writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 40acda270..3ecb456f6 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -669,39 +669,83 @@ fn format_filters() -> impl Iterator &'static [&'static str] { +/// Whether File › Open opens a file of extension `ext` (lower case) as something other than a +/// document: a plug-in (installed), a shortcut set, a script (checked), flattener, PDF, print, +/// perspective grid or export presets (imported), a Libraries panel library, or a swatch or graphic +/// style library. +pub fn opens_as_other(ext: &str) -> bool { + [ + crate::cmd::plugin::EXTS, + crate::cmd::shortcutset::EXTS, + crate::cmd::scripts::EXTS, + crate::cmd::flatten::PRESET_EXTS, + crate::cmd::pdfcmds::PRESET_EXTS, + crate::cmd::printpresets::PRESET_EXTS, + crate::cmd::perspgrid::PRESET_EXTS, + screens::PRESET_FILE_EXTS, + crate::cmd::library::LIBRARY_EXTS, + crate::cmd::swatchlib::LIBRARY_EXTS, + std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT), + ] + .iter() + .any(|exts| exts.contains(&ext)) +} + +/// File › Open's extensions and filters for the installed importer plug-ins. +type ImporterPart = (&'static [&'static str], Vec<(&'static str, &'static [&'static str])>); + +/// File › Open's part for the installed importer plug-ins: the extensions of "All readable files" +/// ([`OPEN_EXTS`], then the importers' extensions in plug-in id order) and one filter per importer +/// that has extensions left, under its name. An importer's extensions leave out those a built-in +/// format reads, which File › Open reads with that format, and those File › Open opens as something +/// else ([`opens_as_other`]). Made once for each set of installed plug-ins, so it lives as long as +/// the dialogs that hold it. +fn importer_open_part() -> ImporterPart { use vectorcraft_plugins::registry; - static MADE: std::sync::Mutex> = std::sync::Mutex::new(None); + static MADE: std::sync::Mutex> = std::sync::Mutex::new(None); let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); let revision = registry::revision(); - if let Some((r, exts)) = *made - && r == revision + if let Some((r, part)) = made.as_ref() + && *r == revision { - return exts; + return part.clone(); } let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec(); - for e in importer_formats().iter().flat_map(|f| f.extensions.iter().copied()) { - if !exts.contains(&e) && readable_format(e).is_none() { - exts.push(e); + let mut filters = Vec::new(); + for f in importer_formats() { + let own: Vec<&'static str> = f.extensions.iter().copied().filter(|e| readable_format(e).is_none() && !opens_as_other(e)).collect(); + if own.is_empty() { + continue; } + for &e in &own { + if !exts.contains(&e) { + exts.push(e); + } + } + let own: &'static [&'static str] = if own.len() == f.extensions.len() { f.extensions } else { Box::leak(own.into_boxed_slice()) }; + filters.push((f.label, own)); } let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) }; - *made = Some((revision, exts)); - exts + let part = (exts, filters); + *made = Some((revision, part.clone())); + part +} + +/// Every extension File › Open reads as a document: [`OPEN_EXTS`], then the extensions the +/// installed importer plug-ins list that no built-in format reads and that File › Open doesn't open +/// as something else ([`opens_as_other`]), in plug-in id order. +pub fn open_extensions() -> &'static [&'static str] { + importer_open_part().0 } /// File › Open's filters: "All readable files" first, then one per readable format, then one per -/// importer plug-in (under its name), then swatch libraries (which open in the library panel), -/// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked -/// without running them) and plug-ins (installed). +/// importer plug-in (under its name, with the extensions [`open_extensions`] takes from it), then +/// swatch libraries (which open in the library panel), Libraries panel libraries, flattener, PDF, +/// print and export presets (imported), scripts (checked without running them) and plug-ins +/// (installed). pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", open_extensions())) - .chain(format_filters()) - .chain(importer_formats().into_iter().map(|f| (f.label, f.extensions))) - .chain(other_open_filters()) + let (all, importers) = importer_open_part(); + std::iter::once(("All readable files", all)).chain(format_filters()).chain(importers).chain(other_open_filters()) } /// [`open_filters`] without the importer plug-ins: "All readable files" lists [`OPEN_EXTS`]. For diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index f5cfea80b..7cb8d1a0a 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -157,6 +157,27 @@ fn built_in_formats_keep_their_files() { assert_eq!(fileio::open_extensions().iter().filter(|e| **e == "svg").count(), 1); } +/// File › Open lists an importer's extensions only where it would open the file with the importer: +/// not those a built-in format reads, and not those it opens as a plug-in, a shortcut set, a +/// script, presets or a library. An importer with none of its own gets no filter. +#[test] +fn file_open_lists_only_the_extensions_it_opens_with_an_importer() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.claimed", r#"["tcl","ase","wasm","kys","jsx","vcstyles","svg"]"#, "{}"), RECT)); + install(&mut s, &tpl::returning(&tpl::importer("test.claimedonly", r#"["ase","gpl"]"#, "{}"), RECT)); + for ext in ["ase", "gpl", "wasm", "kys", "jsx", "vcstyles"] { + assert!(fileio::opens_as_other(ext), "{ext}"); + assert!(!fileio::open_extensions().contains(&ext), "All readable files: {ext}"); + } + assert!(!fileio::opens_as_other("tcl") && !fileio::opens_as_other("svg")); + assert!(fileio::open_extensions().contains(&"tcl")); + let filters: Vec<_> = fileio::open_filters().collect(); + assert!(filters.contains(&("Test test.claimed", &["tcl"][..])), "{filters:?}"); + assert!(!filters.iter().any(|(label, _)| *label == "Test test.claimedonly"), "{filters:?}"); + s.execute("plugin.remove", &json!({"id": "test.claimed"})).unwrap(); + s.execute("plugin.remove", &json!({"id": "test.claimedonly"})).unwrap(); +} + /// Several importers list one extension: the one whose id sorts first opens the file, and /// `importer` picks another (by its format id or its id). #[test] diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index eab73422e..9d75db484 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -164,6 +164,47 @@ fn only_file_open_s_dialog_lists_an_importer_s_files() { app.run("plugin.remove", json!({"id": "test.ui.filters"})).unwrap(); } +/// The extensions File › Open's filters leave out of importers' (`fileio::opens_as_other`) are the +/// ones File › Open installs, imports, checks or loads as something other than a document. +#[test] +fn importer_filters_leave_out_what_file_open_opens_as_something_else() { + use vectorcraft_engine::cmd::{self, fileio}; + let lists: [&[&str]; 10] = [ + cmd::plugin::EXTS, + cmd::shortcutset::EXTS, + cmd::scripts::EXTS, + cmd::flatten::PRESET_EXTS, + cmd::pdfcmds::PRESET_EXTS, + cmd::printpresets::PRESET_EXTS, + cmd::perspgrid::PRESET_EXTS, + fileio::SCREEN_EXPORT_PRESET_EXTS, + cmd::library::LIBRARY_EXTS, + cmd::swatchlib::LIBRARY_EXTS, + ]; + for ext in lists.iter().flat_map(|l| l.iter().copied()).chain([vectorcraft_doc::style_libs::STYLES_EXT]) { + assert!(crate::io::opens_as(&format!("a.{ext}")).is_some(), "{ext}"); + assert!(fileio::opens_as_other(ext), "{ext}"); + } + for ext in ["svg", "tcl", "pdf"] { + assert!(crate::io::opens_as(&format!("a.{ext}")).is_none() && !fileio::opens_as_other(ext), "{ext}"); + } + // File › Open's dialog lists an importer's `.tuo` but not its `.ase` (a swatch library there). + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.claimed", r#"["tuo","ase"]"#, "{}"), tpl::ECHO, "", 1)); + let asked: std::rc::Rc>> = Default::default(); + let seen = asked.clone(); + app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| { + *seen.borrow_mut() = Some(pick.clone()); + None + })); + let _ = app.run("file.open", json!({})); + let pick = asked.borrow_mut().take().expect("File › Open showed its dialog"); + let own = pick.filters.iter().find(|(label, _)| label == "Test test.ui.claimed").map(|(_, exts)| exts.clone()); + assert_eq!(own, Some(vec!["tuo".to_string()]), "{:?}", pick.filters); + assert!(!pick.filters[0].1.iter().any(|e| e == "ase"), "All readable files: {:?}", pick.filters[0]); + app.run("plugin.remove", json!({"id": "test.ui.claimed"})).unwrap(); +} + /// File › Open reads a file of an extension only an importer lists, and refuses one past the /// importer's input limit before reading it, as Open With does. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 8a5e83f18..8d77e4d82 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6091,7 +6091,7 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc `effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` -with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: +with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?: "plugin.", params?}` (and `open_file {path, importer?, params?}`) opens a file whose extension no built-in format reads, by its content or its extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). `importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such diff --git a/docs/plugins.md b/docs/plugins.md index 7329488df..187add504 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -41,8 +41,8 @@ An exporter adds a write-only format, `plugin.`, which `document.formats` li `document.export {"format": "plugin.", "params"?: {…}, "path"?}` writes it, and so does a `path` ending in one of its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. -An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id, its extensions -in `openExtensions`. `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format +An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id. `openExtensions` +holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the one whose id sorts first opens it. `document.open {"importer": "plugin.", "params"?: {…}}` names the importer, which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` @@ -60,7 +60,10 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New -from Template and Load Styles, list only the files the built-in formats read); **File › Open With** lists the +from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's +extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`, +`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give +those files to the importer, and Open With does. **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. An importer with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open From a5ea32b3869e3b67a43355c113113232b97fa9dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 07:31:27 -0800 Subject: [PATCH 18/38] Effects: fitted gradients turn with the Transform effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gradient fitted to the object refit to the outline after the object's Transform effect rotated, reflected or scaled it unevenly, so the gradient kept running left to right while the shape turned under it. Object › Transform already turns such a gradient. A Transform effect that a refit would not reproduce now fixes the gradient to its fit on the geometry the effect takes, and then maps it with that effect and the effects after it, as a placed gradient. Under moves and even scales it draws as a refit does, and an effect that scales the object to nothing across leaves it fitted. This applies to an object's own effects, a fill's or stroke's own effects, and a group's: in a group, the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to the fit on their own object. Object › Expand of the fill draws the same. This changes how existing documents draw: art with a fitted gradient and a Transform effect that rotates, reflects or scales it unevenly now shows the gradient turned with the art. How gradients follow the effect is VectorCraft's own rule. --- crates/doc/src/node.rs | 1 + crates/doc/src/node/ops.rs | 2 +- crates/effects/src/bake/tests.rs | 119 +++++++++++++++++++++++++++ crates/effects/src/catalog.rs | 2 +- crates/effects/src/geometry.rs | 56 +++++++++---- crates/effects/src/group/tests.rs | 64 ++++++++++++++ crates/effects/src/reshape.rs | 16 +++- crates/engine/src/tests/objexpand.rs | 27 ++++++ crates/render/src/tests/objectfx.rs | 35 ++++++++ docs/target-app-parity.md | 1 + 10 files changed, 303 insertions(+), 20 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 3dd07351e..550ac3770 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -31,6 +31,7 @@ pub use ids::{LAYER_COLORS, LayerColor, NodeId}; pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget}; pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; +pub use ops::keeps_gradient_fit; use wire::NodeWire; pub use wire::{MAX_READ_NODES, with_node_budget}; diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 2b2bef296..c15b1d05e 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -768,7 +768,7 @@ impl Node { } /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? -fn keeps_gradient_fit(a: Affine) -> bool { +pub fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); let eps = 1e-12 * m0.abs().max(1.0); m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 0ff1cdac0..d42199a68 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -210,6 +210,125 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo assert_eq!(baked(&fitted, distort()).1.geom, None); } +/// A gradient fitted to the object turns, reflects and stretches with a Transform effect that a +/// refit would not reproduce: it is fixed to its fit on the geometry the effect takes, then mapped. +/// Moves and even scales leave it fitted. +#[test] +fn a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = |kind| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops.clone())))); + let geom = |start: (f64, f64), end: (f64, f64)| GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None }; + // The geometry of the baked fill (`own`: the Transform effect is the fill's own effect). + let baked = |kind, effects: &[Effect], own: bool| { + let mut ap = Appearance::basic(fitted(kind), Paint::None, 0.0); + if own { + *ap.items[0].effects_mut() = effects.to_vec(); + } + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + if !own { + n.appearance.effects = effects.to_vec(); + } + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + if let Paint::Gradient(g) = c.appearance.fill_paint() { + found.push(g.geom); + } + }); + assert_eq!(found.len(), 1, "{effects:?}, own {own}"); + found[0] + }; + let t = |params| fx("distort.transform", params); + let offset = fx("path.offsetPath", json!({"offset": 10.0, "joins": "miter"})); + let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]}); + let linear = GradientKind::Linear; + let cases = [ + // A quarter turn about the center: the left to right ramp runs from the bottom up. + (linear, vec![t(json!({"rotate": 90.0}))], geom((50.0, 100.0), (50.0, 0.0))), + // Reflected left to right: the ramp runs from right to left. + (linear, vec![t(json!({"reflectX": true}))], geom((100.0, 50.0), (0.0, 50.0))), + // With Scale Strokes & Effects on, the same. + (linear, vec![t(json!({"rotate": 90.0, "strokes": true}))], geom((50.0, 100.0), (50.0, 0.0))), + // Offset Path grows the square to -10..110 first: the turn takes the fit across 120 pt. + (linear, vec![offset.clone(), t(json!({"rotate": 90.0}))], geom((50.0, 110.0), (50.0, -10.0))), + // Stretched to twice the width: the circle of radius 50 about the center becomes an + // ellipse 100 pt across and 50 pt tall. + (GradientKind::Radial, vec![t(json!({"scaleH": 200.0}))], GradientGeom { aspect: 0.5, ..geom((50.0, 50.0), (150.0, 50.0)) }), + // Turned, then moved one width right by Free Distort: the fixed gradient moves with it. + (linear, vec![t(json!({"rotate": 90.0})), fx("distort.freeDistort", corners)], geom((150.0, 100.0), (150.0, 0.0))), + ]; + for (kind, effects, want) in cases { + for own in [false, true] { + let what = format!("{kind:?} {effects:?}, own {own}"); + let got = baked(kind, &effects, own).unwrap_or_else(|| panic!("{what}: fixed to its fit")); + assert_geom_near(got, want, &what); + } + } + // A move and an even scale are what a refit gives: the gradient stays fitted. + for effects in [vec![t(json!({"moveH": 30.0}))], vec![t(json!({"scaleH": 150.0, "scaleV": 150.0})), offset]] { + for own in [false, true] { + assert_eq!(baked(linear, &effects, own), None, "{effects:?}, own {own}"); + } + } + // A stroke's gradient is fixed to its fit on the box grown by half its weight (4 pt), -2..102, + // with Scale Strokes & Effects off or on. + let stroked = |effects: &[Effect], own: bool| { + let mut ap = Appearance::basic(Paint::None, fitted(linear), 4.0); + let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") }; + if own { + *stroke.effects_mut() = effects.to_vec(); + } + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + if !own { + n.appearance.effects = effects.to_vec(); + } + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + found.push(g.geom); + } + } + }); + assert_eq!(found.len(), 1, "{effects:?}, own {own}"); + found[0] + }; + for strokes in [false, true] { + for own in [false, true] { + let what = format!("stroke, strokes {strokes}, own {own}"); + let got = stroked(&[t(json!({"rotate": 90.0, "strokes": strokes}))], own).unwrap_or_else(|| panic!("{what}: fixed to its fit")); + assert_geom_near(got, geom((50.0, 102.0), (50.0, -2.0)), &what); + } + } +} + +/// A Transform effect that scales the geometry to nothing across leaves a fitted gradient fitted: +/// mapping it with the effect would flatten it to one color. +#[test] +fn a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted, 4.0)); + n.appearance.effects = vec![fx("distort.transform", json!({"scaleH": 0.0, "rotate": 30.0}))]; + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + found.push(g.geom); + } + } + }); + assert_eq!(found, [None]); +} + #[test] fn item_effects_split_into_one_path_per_item() { let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a014807a7..8a34fc0d7 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -238,7 +238,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 715143e1a..b9799ef93 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -174,21 +174,34 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R (out.to_bezpath(), carry) } -/// How placed gradients follow geometry effects. The Transform effect without copies maps them as -/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves -/// and scales them from the object's bounds before the effect to its bounds after it. For the -/// other geometry effects it is empty. It does not change gradients fitted to the object. +/// How gradients follow geometry effects. The Transform effect without copies maps placed +/// gradients as it maps the object (with copies, `crate::reshape` gives each copy its own). Free +/// Distort moves and scales them from the object's bounds before the effect to its bounds after +/// it. For the other geometry effects it is empty. A gradient fitted to the object fits the +/// geometry each effect makes, until a Transform effect turns, reflects or scales it unevenly +/// (a map after which a refit would not match the mapped gradient): it is fixed to its fit on the +/// geometry that effect takes, and then follows that effect and the ones after it as a placed +/// gradient does. #[derive(Clone, Debug, Default, PartialEq)] pub struct Carry(Vec); +/// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit +/// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map +/// that flattens the geometry to a line (a scale of 0) leaves the gradient fitted, since mapping it +/// would flatten it to one color. +pub(crate) fn fixes_fit(m: vectorcraft_geom::Affine) -> bool { + let [a, b, c, d, _, _] = m.as_coeffs(); + let norm = a * a + b * b + c * c + d * d; + m.is_finite() && m.determinant().abs() > 1e-12 * norm && !vectorcraft_doc::node::keeps_gradient_fit(m) +} + #[derive(Clone, Copy, Debug, PartialEq)] enum CarryStep { - Affine(vectorcraft_geom::Affine), + /// Map `m`; `from` is the geometric bounds of the geometry it takes, which a fitted gradient + /// fits before `m` moves it (none: fitted gradients stay fitted). + Affine { m: vectorcraft_geom::Affine, from: Option }, /// From box `from` to box `to` (strokes: their stroke-inflated boxes). - Boxes { - from: Rect, - to: Rect, - }, + Boxes { from: Rect, to: Rect }, } impl Carry { @@ -220,7 +233,7 @@ impl Carry { return Carry::default(); } let step = match id { - "distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)), + "distort.transform" => Some(CarryStep::Affine { m: distort::transform_map(b, p, seed).0, from }), _ => match (from, to) { (Some(from), Some(to)) if from != to && [to.x0, to.y0, to.x1, to.y1].iter().all(|v| v.is_finite()) => { Some(CarryStep::Boxes { from, to }) @@ -239,17 +252,32 @@ impl Carry { /// Does it move any of the gradients of `ap`? Callers check first, so an appearance shared /// with other objects is not copied for nothing. pub fn moves(&self, ap: &Appearance) -> bool { - !self.0.is_empty() && ap.has_placed_gradient() + !self.0.is_empty() && (ap.has_placed_gradient() || (self.pin().is_some() && ap.has_unplaced_gradient())) } - /// Move the placed gradients of `ap` along, through each step in turn. + /// The first step that fixes gradients fitted to the object: a map a refit would not + /// reproduce, on geometry whose bounds are known. + fn pin(&self) -> Option { + self.0.iter().position(|s| matches!(s, CarryStep::Affine { m, from: Some(_) } if fixes_fit(*m))) + } + + /// Move the gradients of `ap` along. Placed gradients take every step in turn; fitted ones + /// are fixed to their fit at the first step [`Self::pin`] finds and take it and the steps + /// after it. pub fn apply(&self, ap: &mut Appearance) { if !self.moves(ap) { return; } - for s in &self.0 { + // The steps move placed gradients only; fitted ones become placed at the pin. + let pin = self.pin(); + for (k, s) in self.0.iter().enumerate() { + if Some(k) == pin + && let CarryStep::Affine { from: Some(b), .. } = *s + { + ap.pin_gradients(b); + } match *s { - CarryStep::Affine(m) => ap.transform_gradients(m), + CarryStep::Affine { m, .. } => ap.transform_gradients(m), CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to), } } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index a0cddc94d..fa02ff004 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -450,6 +450,70 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() { } } +/// A group's Transform effect that turns its art turns the gradients fitted to its paths, to the +/// group's own fill, to a nested group's own fill and to an image's and a symbol instance's own +/// fills: each is fixed to its fit on its own object first. A move leaves the members' gradients +/// fitted. +#[test] +fn a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::ImageObject; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let square = |id: u64, x: f64| { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(fitted.clone(), Paint::None, 0.0))) + }; + let mut image = Node::new( + NodeId(4), + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::translate((30.0, 0.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + image.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into(); + // A symbol instance whose symbol is not in a document is measured as the 20 pt square about + // its origin: here 22.5..27.5 across and 2.5..7.5 down. + let at = vectorcraft_geom::Affine::translate((25.0, 5.0)) * vectorcraft_geom::Affine::scale(0.25); + let mut instance = Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: at }); + instance.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into(); + // Ends of each gradient by object id; none for a gradient still fitted. + let ends = |params: serde_json::Value| { + let mut inner = Node::group(NodeId(5), vec![square(2, 10.0)]); + inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone()))); + let mut g = Node::group(NodeId(10), vec![square(1, 0.0), Arc::new(inner), Arc::new(image.clone()), Arc::new(instance.clone())]); + g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone()))); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut out = std::collections::BTreeMap::new(); + art.walk(&mut |c: &Node| { + if let Paint::Gradient(p) = c.appearance.fill_paint() { + out.insert(c.id.0, p.geom.map(|g| (g.start, g.end))); + } + }); + out + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + // A quarter turn about the group's center (20, 5): (x, 5) goes to (20, 5 + 20 - x), so a ramp + // from (x0, 5) to (x1, 5) runs from (20, 25 - x0) up to (20, 25 - x1). + let turned = ends(serde_json::json!({"rotate": 90})); + let want = [(1, 0.0, 10.0), (2, 10.0, 20.0), (5, 10.0, 20.0), (4, 30.0, 40.0), (6, 22.5, 27.5), (10, 0.0, 40.0)]; + for (id, x0, x1) in want { + let got = turned.get(&id).copied().flatten(); + let ok = got.is_some_and(|(s, e)| near(s, Point::new(20.0, 25.0 - x0)) && near(e, Point::new(20.0, 25.0 - x1))); + assert!(ok, "object {id}: {got:?} in {turned:?}"); + } + // The group's own fill is fixed to its fit on the art it paints, which the move took to 30..70. + let moved = ends(serde_json::json!({"moveH": 30})); + let group = moved.get(&10).copied().flatten(); + assert!(group.is_some_and(|(s, e)| near(s, Point::new(30.0, 5.0)) && near(e, Point::new(70.0, 5.0))), "{moved:?}"); + assert!(moved.len() == 6 && [1, 2, 4, 5, 6].iter().all(|id| moved[id].is_none()), "{moved:?}"); +} + #[test] fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 4ea3e4610..9382fc5b5 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -714,8 +714,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; }; used = used.minus(before).plus(Budget::of(&out)); - if follows && !boxes && leaf.appearance.has_placed_gradient() { - crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance); + if follows && !boxes { + // A gradient fitted to the path fits the path's own bounds. + let carry = crate::Carry::of(&e.id, ¶ms, bounds, path.bounds(), None, seed); + if carry.moves(&leaf.appearance) { + carry.apply(&mut leaf.appearance); + } } set_geometry(leaf, out, rule); }); @@ -1048,7 +1052,7 @@ fn is_member_group(n: &Node) -> bool { } /// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a -/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and +/// gradient: the child indices that lead to it from the art, its geometric bounds, and /// whether a path below it kept its shape. type GroupMark = (Vec, Option, bool); @@ -1065,7 +1069,7 @@ fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut for (i, c) in children.iter().enumerate() { at.push(i); let (b, h) = group_marks(c, at, kept, out); - if is_member_group(c) && c.appearance.has_placed_gradient() { + if is_member_group(c) && (c.appearance.has_placed_gradient() || c.appearance.has_unplaced_gradient()) { out.push((at.clone(), b, h)); } at.pop(); @@ -1218,6 +1222,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { let src = (**c).clone(); let placed = |xf: Affine| { let mut one = src.clone(); + // Gradients fitted to it are fixed to their fit first when a refit would not match. + if crate::geometry::fixes_fit(xf) { + one.pin_gradients(); + } match &mut one.kind { NodeKind::Image(im) => im.xf = xf * im.xf, NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at, diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 4210a1d96..64e495b5e 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -164,6 +164,33 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_ } } +/// A gradient fitted to the object turns with a Transform effect on the canvas, and Object › Expand +/// of the fill makes strips that draw the same. +#[test] +fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() { + // The gray level at (x, y). + let gray = |r: &vectorcraft_render::Rendered, x: usize, y: usize| r.pixels[(y * 200 + x) * 4] as i32; + // The object's own effect, and the fill's (the first appearance item). + for (params, item) in + [(json!({"rotate": 90}), json!(null)), (json!({"rotate": 90}), json!(0)), (json!({"rotate": 30, "scaleH": 60}), json!(null))] + { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(60.0, 60.0, 140.0, 140.0), white_black(), false); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + if params["rotate"] == 90 { + // The white to black ramp runs up or down the turned square, not across it. + let (top, bottom) = (gray(&before, 100, 70), gray(&before, 100, 130)); + let (left, right) = (gray(&before, 70, 100), gray(&before, 130, 100)); + assert!((top - bottom).abs() > 100 && (left - right).abs() < 20, "{params} on item {item}: {top} {bottom} {left} {right}"); + } + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{params} on item {item}: mean difference {diff}"); + } +} + #[test] fn eight_strips_match_the_gradient_samples() { let mut s = session(); diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 173d91bb0..316f2b19b 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -857,6 +857,41 @@ fn a_paths_gradient_moves_with_its_transform_and_free_distort() { assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50)); } +/// The canvas turns and reflects a gradient fitted to the square with its Transform effect, on the +/// object and as the fill's own effect. +#[test] +fn a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas() { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let draw = |e: Effect, own: bool| { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(fitted.clone(), Paint::None, 0.0)); + if own { + n.appearance.items[0].effects_mut().push(e); + } else { + n.appearance.effects.push(e); + } + render(&doc_with(Document::new(100.0, 100.0), n)) + }; + let rb = |img: &Rendered, x: u32, y: u32| { + let p = img.pixel(x, y); + (p[0], p[2]) + }; + let red = |(r, b): (u8, u8)| r > 200 && b < 60; + let blue = |(r, b): (u8, u8)| b > 200 && r < 60; + for own in [false, true] { + // A quarter turn: the left (red) to right (blue) ramp runs from the bottom up. + let img = draw(fx("distort.transform", json!({"rotate": 90})), own); + let (top, bottom) = (rb(&img, 25, 36), rb(&img, 25, 63)); + assert!(blue(top) && red(bottom), "own {own}: {top:?} {bottom:?}"); + assert_eq!(rb(&img, 12, 50), rb(&img, 38, 50), "own {own}: the same color across"); + // Reflected left to right: blue on the left, red on the right. + let img = draw(fx("distort.transform", json!({"reflectX": true})), own); + let (left, right) = (rb(&img, 11, 50), rb(&img, 38, 50)); + assert!(blue(left) && red(right), "own {own}: {left:?} {right:?}"); + } +} + #[test] fn darkness_subtracts_from_rgb_before_shadow_opacity() { for opacity in [50.0, 100.0] { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdb4efaa1..78f040f84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -539,6 +539,7 @@ since 2026-10-01. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. From 2772a44efd95f2c438656a7a09e307e3d28548e0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 08:09:23 -0800 Subject: [PATCH 19/38] Plug-ins: Open With runs an importer off the UI thread, and Cancel stops it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open With reads a picked file on the thread File › Open uses, with the importer and its parameters, so a slow importer does not freeze the window. A call that names path still gets document.open's result. Cancel in the progress window now raises a cancel token on that thread, which stops a running importer between two slices of its budget. --- crates/ui-egui/src/dialogs/plugin.rs | 6 +- .../src/dialogs/tests_plugin_import.rs | 11 +++ crates/ui-egui/src/io.rs | 2 +- crates/ui-egui/src/io/open.rs | 26 ++++++- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/opening.rs | 22 ++++-- crates/ui-egui/src/opening/tests.rs | 68 +++++++++++++++++++ docs/mcp.md | 6 +- docs/plugins.md | 4 +- 9 files changed, 133 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index a5736c47e..001bfd695 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -227,7 +227,8 @@ pub(super) const IMPORT_SPEC: DialogSpec = DialogSpec { }; /// Open With with importer `plugin`, which has parameters: its fields at their defaults, and -/// `open`, Open With's own parameters (`path`, `importer`), kept for Open. +/// `open`, Open With's own parameters (`path`, `importer`, and `picked` for a file from the +/// picker, which is read off the UI thread), kept for Open. pub fn open_import(app: &mut VectorcraftApp, plugin: &Plugin, open: Value) { let mut fields = fields_for(plugin.manifest()); fields.insert("__open".into(), open); @@ -239,5 +240,6 @@ fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result let mut p = d.fields.get("__open").cloned().filter(Value::is_object).ok_or("no file to open")?; p["params"] = form::params(d); app.ui.dialog = None; - crate::io::open_with(app, &p) + let picked = p["picked"] == true; + crate::io::open_with_on(app, &p, picked && crate::opening::available()) } diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index 9d75db484..2c1e2d091 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -113,6 +113,17 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0)); assert_eq!(app.ui.recent_files, std::slice::from_ref(&file)); + // Only a file from the picker goes off the UI thread after the dialog: a caller naming `path` + // gets the document. + app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], false); + app.ui.dialog = None; + let picked = file.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + app.run("file.openWith", json!({"importer": "plugin.test.ui.params"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], true); + app.ui.dialog = None; + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file, "params": {"density": 7}})).unwrap(); assert_eq!(r["format"], "plugin.test.ui.params"); assert!(app.ui.dialog.is_none()); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 41694fdbe..bba294f0a 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -21,7 +21,7 @@ pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, e pub(crate) use export::{ export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, }; -pub(crate) use open::{EXPORT_PRESETS, import_presets, opened}; +pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, opened}; pub use open::{ MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, open_path, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error, diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index fc0aa8481..5373ce9d6 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -264,8 +264,21 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { /// failure is reported as File › Open reports it. An importer with parameters, given no `params`, /// asks for them first in dialog `pluginImport`, at their defaults. The web's file picker can't /// carry the importer: there File › Open's picker opens, and the file opens as File › Open opens -/// it. +/// it. A file the user picked is read off the UI thread where that is available +/// ([`crate::opening`]), and the result is then `{background: true, status}`; with `path` the file +/// opens before the reply, as automation needs. pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { + open_with_on(app, p, picks_a_file(p) && crate::opening::available()) +} + +/// Do File › Open With's parameters leave the file to the picker? +fn picks_a_file(p: &Value) -> bool { + p.get("path").and_then(Value::as_str).is_none_or(str::is_empty) +} + +/// [`open_with`]; with `background`, the importer runs off the UI thread +/// ([`crate::opening::start_with`]), its progress window offering Cancel. +pub(crate) fn open_with_on(app: &mut VectorcraftApp, p: &Value, background: bool) -> Result { let importer = p.get("importer").and_then(Value::as_str).ok_or("missing `importer` (see document.formats)")?; let plugin = vectorcraft_plugins::import::named(importer).map_err(|e| e.to_string())?; let path = match p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()) { @@ -285,14 +298,21 @@ pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { let given = p.get("params").filter(|v| !v.is_null()); let opened = vectorcraft_engine::file_access::check_read(&path).and_then(|()| check_import_size(&path, true)).and_then(|()| { if given.is_none() && !plugin.manifest().params.is_empty() { - dialogs::plugin::open_import(app, &plugin, json!({ "path": path, "importer": importer })); + dialogs::plugin::open_import(app, &plugin, json!({ "path": path, "importer": importer, "picked": picks_a_file(p) })); return Ok(Value::Null); } let mut o = json!({ "importer": importer }); if let Some(params) = given { o["params"] = params.clone(); } - let bytes = read(app, &path)?; + let mut bytes = read(app, &path)?; + if background { + match crate::opening::start_with(app, &path, bytes, o.clone()) { + Ok(r) => return Ok(r), + // No thread: at once. + Err(back) => bytes = back, + } + } let r = open_document(app, &path, &bytes, Some(path.clone()), &o)?; note_recent(app, &path); Ok(r) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index debd6c224..d310e9318 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -34,7 +34,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.openWith", "Open With", "", - "{importer: \"plugin.\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → document.open's result | null while the picker is open", + "{importer: \"plugin.\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → with a path, document.open's result; a picked file is read off the UI thread on the desktop, {background: true, status}, and opens once the importer returns (file.cancelOpen stops it) | null while the picker is open", ), ( "file.save", diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 9191509ae..99e0e9d6b 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; use serde_json::{Value, json}; +use vectorcraft_engine::cancel::{self, CancelToken}; use vectorcraft_engine::cmd::fileio::{self, ReadFile}; use vectorcraft_engine::file_access::ScopeHandle; @@ -30,6 +31,8 @@ pub struct Pending { /// The automation roots in force where the open started: the document is installed under /// them too (its links are read as it opens). scope: ScopeHandle, + /// Raised by Cancel, on the thread reading the file: an importer plug-in stops. + token: CancelToken, /// Continue in Background: the progress window no longer shows it. pub hidden: bool, } @@ -65,6 +68,11 @@ pub fn available() -> bool { /// own ([`fileio::read_for_open`]) → `{background: true, status}`, or the bytes back when no thread /// could start (the caller opens the file at once). pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result> { + start_with(app, path, bytes, Value::Null) +} + +/// [`start`] with the `document.open` options in `params` (`importer` and its `params`). +pub fn start_with(app: &mut VectorcraftApp, path: &str, bytes: Vec, params: Value) -> Result> { let (tx, done) = channel(); let bytes = Arc::new(bytes); let (name, file) = (path.to_string(), bytes.clone()); @@ -74,11 +82,15 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result) -> Result Value { let cancelled = std::mem::take(&mut app.opening.pending); for p in &cancelled { + p.token.cancel(); // The status bar no longer says it is opening. if app.ui.status == format!("{}…", p.label) { app.ui.status.clear(); diff --git a/crates/ui-egui/src/opening/tests.rs b/crates/ui-egui/src/opening/tests.rs index 1f5a6cbd1..ef62509bf 100644 --- a/crates/ui-egui/src/opening/tests.rs +++ b/crates/ui-egui/src/opening/tests.rs @@ -50,6 +50,74 @@ fn open_ready(app: &mut VectorcraftApp) { poll(app); } +fn install_importer(app: &mut VectorcraftApp, src: &str) { + let wasm = wat::parse_str(src).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap(); +} + +/// A file for the importers below, in a folder of its own. +fn importer_file(tag: &str) -> (std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("vc-ui-opening-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a.tob"); + std::fs::write(&file, "not json").unwrap(); + let file = file.to_string_lossy().into_owned(); + (dir, file) +} + +/// Open With off the UI thread: nothing opens until the importer has read the file with the +/// parameters it was given; then the document opens and joins Open Recent, and a failure is +/// reported in a dialog. +#[test] +fn open_with_runs_the_importer_off_the_ui_thread() { + use vectorcraft_plugins::wat::{self as tpl, Given}; + let rect = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#; + let (dir, file) = importer_file("with"); + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + install_importer(&mut app, &tpl::when_given(&tpl::importer("test.opening.with", r#"["tob"]"#, params), Given::Params, r#""density":7"#, rect)); + + let open = |density: i64| json!({"importer": "plugin.test.opening.with", "path": file, "params": {"density": density}}); + let r = crate::io::open_with_on(&mut app, &open(7), true).unwrap(); + assert_eq!(r["background"], true, "{r}"); + assert!(app.session.documents().is_empty(), "nothing opens before the importer has read the file"); + assert!(app.ui.recent_files.is_empty()); + open_ready(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&file)); + + // Density 4 gives the file back, which isn't JSON. + app.ui.recent_files.clear(); + crate::io::open_with_on(&mut app, &open(4), true).unwrap(); + open_ready(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert!(app.ui.status.contains("Couldn't open a.tob"), "{}", app.ui.status); + assert!(app.ui.dialog.is_some(), "the failure is reported in a dialog"); + assert!(app.ui.recent_files.is_empty()); + app.run("plugin.remove", json!({"id": "test.opening.with"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Cancel stops an importer that never returns, on the thread reading the file. +#[test] +fn cancel_stops_a_running_importer() { + use vectorcraft_plugins::wat as tpl; + let (dir, file) = importer_file("cancel"); + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + install_importer(&mut app, &tpl::module(&tpl::importer("test.opening.spins", r#"["tob"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); + crate::io::open_with_on(&mut app, &json!({"importer": "plugin.test.opening.spins", "path": file}), true).unwrap(); + let done = std::mem::replace(&mut app.opening.pending[0].done, std::sync::mpsc::channel().1); + assert_eq!(cancel_all(&mut app)["cancelled"], 1); + let r = done.recv().unwrap(); + assert!(r.as_ref().err().is_some_and(|e| e.contains("cancelled")), "{:?}", r.err()); + assert!(app.session.documents().is_empty()); + app.run("plugin.remove", json!({"id": "test.opening.spins"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1694: a file that finishes opening while a dialog is up waits for it to close, so the /// dialog's document stays the active one. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 8d77e4d82..4c83cbcd5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6099,8 +6099,10 @@ as `svg`. The importer is given at most 128 MiB of the file; its objects become their bounds, Save then asks for a name, and `warnings` holds the importer's warnings. `plugin.run` of an importer is an error that names `document.open`. In the app, File › Open With lists the installed importers, and `file.openWith {importer, path?, params?}` opens `path`, or a file picked among the importer's extensions, with one (on -the web it opens File › Open's picker) and adds it to Open Recent; an importer with parameters, given no `params`, -first asks for them in the `pluginImport` dialog. `params` holds the importer's parameters (their defaults when +the web it opens File › Open's picker) and adds it to Open Recent. With `path` the reply is `document.open`'s result; +on the desktop a picked file is read off the UI thread and the reply is `{background: true, status}`, and +`file.cancelOpen` stops the importer. An importer with parameters, given no `params`, first asks for them in the +`pluginImport` dialog. `params` holds the importer's parameters (their defaults when omitted). A failing plug-in returns an error and leaves the document as it was (an export writes no file, and an import opens no document). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` diff --git a/docs/plugins.md b/docs/plugins.md index 187add504..eb4537e17 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -65,7 +65,9 @@ extensions that a built-in format reads, and those File › Open installs, impor `.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give those files to the importer, and Open With does. **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its -extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. An importer +extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. On the +desktop the importer runs off the UI thread, and a run that takes a while shows a progress window with Cancel, which +stops it. An importer with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open button opens the file; File › Open runs an importer with its parameters' defaults. **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the From d485558f449d2169efbb465baa56ef5b8f5553a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 08:33:34 -0800 Subject: [PATCH 20/38] Plug-ins: Export As leaves out importers' read-only formats Export As listed every plug-in format, so an importer appeared there and picking it ended in an error that its files can be opened but not written. The list now takes only plug-in formats that write. document.formats still lists importers. The importer parameters test used the same plug-in id as an exporter test in the same process. It has an id of its own. --- crates/ui-egui/src/dialogs/export_as.rs | 2 +- .../src/dialogs/tests_plugin_import.rs | 38 +++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 32ee696af..246aaf23f 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -28,7 +28,7 @@ const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")]; /// The formats Export As writes: the built-in ones, then those of the installed exporter plug-ins. pub(super) fn formats() -> Vec<&'static Format> { - BUILT_IN.iter().copied().chain(fileio::plugin_formats()).collect() + BUILT_IN.iter().copied().chain(fileio::plugin_formats().into_iter().filter(|f| f.write)).collect() } /// Is `f` an exporter plug-in's format? diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index 2c1e2d091..481d1eebf 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -95,15 +95,15 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { let mut app = app(); let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; // RECT when the parameters hold density 7; the file, which isn't JSON, otherwise. - install(&mut app, &tpl::when_given(&tpl::importer("test.ui.params", r#"["tup"]"#, params), Given::Params, r#""density":7.0"#, RECT)); + install(&mut app, &tpl::when_given(&tpl::importer("test.ui.importparams", r#"["tup"]"#, params), Given::Params, r#""density":7.0"#, RECT)); let file = dir.join("a.tup"); std::fs::write(&file, "not json").unwrap(); let file = file.to_string_lossy().into_owned(); - app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file})).unwrap(); + app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file})).unwrap(); assert_eq!(kind(&app), Some(plugin::IMPORT_KIND)); let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.params".to_string(), Some(&json!(4.0)))); + assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.importparams".to_string(), Some(&json!(4.0)))); assert!(app.session.documents().is_empty(), "nothing opened before Open"); frame(&mut app); set(&mut app, "density", json!(7)); @@ -115,17 +115,17 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { // Only a file from the picker goes off the UI thread after the dialog: a caller naming `path` // gets the document. - app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file})).unwrap(); + app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file})).unwrap(); assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], false); app.ui.dialog = None; let picked = file.clone(); app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); - app.run("file.openWith", json!({"importer": "plugin.test.ui.params"})).unwrap(); + app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams"})).unwrap(); assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], true); app.ui.dialog = None; - let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file, "params": {"density": 7}})).unwrap(); - assert_eq!(r["format"], "plugin.test.ui.params"); + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file, "params": {"density": 7}})).unwrap(); + assert_eq!(r["format"], "plugin.test.ui.importparams"); assert!(app.ui.dialog.is_none()); assert_eq!(app.session.documents().len(), 2); @@ -135,14 +135,14 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { let crate::control::Outcome::Done(r) = crate::control::handle(app, &egui::Context::default(), &req) else { panic!("not done") }; r }; - let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.params", "params": {"density": 7}})); - assert_eq!(r["result"]["format"], "plugin.test.ui.params", "{r}"); + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.importparams", "params": {"density": 7}})); + assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}"); assert_eq!(app.session.documents().len(), 3); - let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.params"})); + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.importparams"})); assert!(r.get("error").is_some(), "the defaults (density 4) don't open the file: {r}"); assert_ne!(kind(&app), Some(plugin::IMPORT_KIND)); assert_eq!(app.session.documents().len(), 3); - app.run("plugin.remove", json!({"id": "test.ui.params"})).unwrap(); + app.run("plugin.remove", json!({"id": "test.ui.importparams"})).unwrap(); let _ = std::fs::remove_dir_all(&dir); } @@ -175,6 +175,22 @@ fn only_file_open_s_dialog_lists_an_importer_s_files() { app.run("plugin.remove", json!({"id": "test.ui.filters"})).unwrap(); } +/// Export As lists an installed exporter and not an installed importer, whose format can only be +/// read, and it does not preset the importer's format. +#[test] +fn export_as_lists_exporters_and_not_importers() { + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.readonly", r#"["tur"]"#, "{}"), tpl::ECHO, "", 1)); + install(&mut app, &tpl::module(&tpl::exporter("test.ui.writes", r#"["tuw"]"#, "{}"), tpl::ECHO, "", 1)); + let listed: Vec<&str> = export_as::formats().iter().map(|f| f.id).collect(); + assert!(listed.contains(&"plugin.test.ui.writes"), "{listed:?}"); + assert!(!listed.contains(&"plugin.test.ui.readonly"), "{listed:?}"); + app.run("file.exportAs", json!({"format": "plugin.test.ui.readonly"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "png"); + app.run("plugin.remove", json!({"id": "test.ui.readonly"})).unwrap(); + app.run("plugin.remove", json!({"id": "test.ui.writes"})).unwrap(); +} + /// The extensions File › Open's filters leave out of importers' (`fileio::opens_as_other`) are the /// ones File › Open installs, imports, checks or loads as something other than a document. #[test] From 91d9639e6a2aeba4bf4bbe81ebceebcb5e31bcf2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 08:40:00 -0800 Subject: [PATCH 21/38] Plug-ins: a permission grant covers only the module that was approved A grant records the module's SHA-256, but only its permissions were compared, so a module with other bytes under a granted id inherited the grant when it listed no new permission. A grant now covers a module only when the id, the SHA-256 and every listed permission match. plugin.inspect reports needsApproval for a changed module, plugin.install refuses it without a new grant and leaves the installed module and its grant as they are, Install Plug-in and File > Open ask again, and the plug-in store and plug-in folders skip it. The same bytes install again without asking, and a module with no permissions list or an empty one needs no grant. A grant whose hash is not 64 lower-case hexadecimal digits covers nothing. plugin.install still trusts the client that sends grant; the docs say so. --- crates/engine/src/cmd/plugin.rs | 15 +-- crates/engine/src/store.rs | 17 ++-- crates/engine/src/tests/plugins.rs | 147 ++++++++++++++++++++++++++-- crates/plugins/src/permissions.rs | 49 ++++++++-- crates/plugins/src/registry.rs | 8 +- crates/ui-egui/src/tests/plugins.rs | 20 +++- docs/mcp.md | 6 +- docs/plugins.md | 16 +-- docs/target-app-parity.md | 1 + 9 files changed, 232 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..6457e2cc9 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -333,9 +333,10 @@ fn grant_param(c: &str, p: &Value) -> Result>> { } /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id -/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant -/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. +/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted this +/// very module unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records +/// the grant with the module's hash, keeps it for the next start and installs it → its +/// `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); @@ -362,7 +363,7 @@ fn install(s: &mut Session, p: &Value) -> Result { )); } // A plug-in that lists permissions installs once the user grants them: whoever installs it - // asks them first, unless they already granted its id all of them. + // asks them first, unless they already granted this very module all of them. let m = plugin.manifest(); let wanted = m.permissions(); if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { @@ -526,7 +527,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -535,7 +536,7 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant)", always, inspect ), @@ -545,7 +546,7 @@ pub fn specs() -> Vec { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 02f2bb532..3d8259361 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -224,8 +224,8 @@ impl Session { } /// Keep the plug-ins this session installs in `dir`, and install the ones already there (a - /// module that doesn't load, and one that lists permissions the grants there don't grant it, - /// is skipped with a log record). + /// module that doesn't load, and one that lists permissions the grants there don't grant that + /// very module, is skipped with a log record, and stays in `dir` with its grant). pub fn attach_plugin_store(&mut self, dir: PathBuf) { let grants = stored_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); @@ -244,12 +244,12 @@ impl Session { // module stored here load. Ok(plugin) if plugin.manifest().asks_permissions() => { let m = plugin.manifest(); - match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) { + match grants.get(&m.id).filter(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) { Some(g) => { vectorcraft_plugins::registry::grant(g.clone()); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } - None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()), + None => log::warn!("skipping the stored plug-in {}: the user hasn't approved this module's permissions", path.display()), } } Ok(plugin) => { @@ -261,9 +261,10 @@ impl Session { self.plugin_dir = Some(dir); } - /// Whether `plugin` installs without asking the user: it lists no permissions, or its id was - /// granted every one it lists, in the grants of the attached plug-in store when there is one, - /// else in this process's ([`vectorcraft_plugins::registry::approved`]). + /// Whether `plugin` installs without asking the user: it lists no permissions, or this very + /// module (its id and SHA-256) was granted every one it lists, in the grants of the attached + /// plug-in store when there is one, else in this process's + /// ([`vectorcraft_plugins::registry::approved`]). pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { self.plugin_approver()(plugin) } @@ -275,7 +276,7 @@ impl Session { move |plugin| { let m = plugin.manifest(); match &grants { - Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())), + Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())), _ => vectorcraft_plugins::registry::approved(plugin), } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index fdfe60067..7d0983ef2 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -532,9 +532,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec { /// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), /// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's -/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same -/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the -/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies. +/// hash. The grant is recorded with that hash and covers only that module: one with other bytes +/// needs a new grant. Removing it forgets the grant. A plug-in without `permissions` installs as +/// before, with the ones its kind implies. #[test] fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { let mut s = session(); @@ -570,11 +570,10 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { assert_eq!(installed["permissions"], json!(["doc.read"])); let g = vectorcraft_plugins::registry::granted(id).unwrap(); assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..])); - // Replaced with another module that lists the same permissions: no new grant needed, and the - // grant now records the new module's hash. + // Replaced with another module that lists the same permissions: granted again, the grant now + // records the new module's hash. let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); - assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)})); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"]})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); // One that asks for more is refused until it is granted all of them. let more = data(r#"["doc.read","doc.write"]"#); @@ -594,6 +593,140 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false); } +/// A module with other bytes under an id that was granted is not covered, even when it lists the +/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed +/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes +/// install again without asking, and a module that lists no permissions never asks. +#[test] +fn a_grant_covers_only_the_module_that_was_approved() { + use vectorcraft_plugins::permissions::sha256_hex; + let mut s = session(); + let id = "test.engine.permissions.bytes"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed)); + let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed)); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true); + let e = s.execute("plugin.install", &json!({"dataBase64": changed_data})).unwrap_err().to_string(); + assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}"); + let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "hash": changed_hash})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "`hash` is not an approval: {e}"); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays"); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays"); + // The same bytes need no new grant. + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false); + run(&mut s, "plugin.install", json!({"dataBase64": first_data})); + // A new grant installs the changed module, and the old bytes are no longer covered. + run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash})); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true); + run(&mut s, "plugin.remove", json!({"id": id})); + + // A manifest without a list, or with an empty one, asks for nothing, whatever its bytes. + let exempt = "test.engine.permissions.exempt"; + for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] { + let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out)); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}"); + run(&mut s, "plugin.install", json!({"dataBase64": data})); + } + run(&mut s, "plugin.remove", json!({"id": exempt})); +} + +/// A stored module whose bytes changed is skipped at the next start and stays in the store with its +/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing. +/// With a store attached, a matching grant held only by the process doesn't count. +#[test] +fn a_stored_grant_covers_only_the_stored_module_it_recorded() { + use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; + let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); + let id = "test.engine.permissions.storedbytes"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::fs::read_dir(&folder.0) + .unwrap() + .map(|e| e.unwrap().path()) + .find(|p| p.extension().is_some_and(|x| x == "wasm")) + .expect("the stored module"); + let forget = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + }; + let starts = || { + forget(); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::get(id).is_some() + }; + assert!(starts(), "the module that was approved loads"); + std::fs::write(&module, &changed).unwrap(); + assert!(!starts(), "other bytes under the old grant are skipped"); + assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store"); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays"); + std::fs::write(&module, &first).unwrap(); + assert!(starts(), "the approved bytes load again"); + + // A malformed hash is no wildcard, even for the module on disk. + let upper = sha256_hex(&first).to_uppercase(); + for hash in ["", "00", upper.as_str()] { + std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap(); + assert!(!starts(), "hash {hash:?}"); + } + + // The store's grants are the ones that count, even when the process holds one for these bytes. + std::fs::write(&grants, &recorded).unwrap(); + assert!(starts()); + let mut attached = session(); + attached.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] }); + let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)})); + assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached"); + run(&mut attached, "plugin.remove", json!({"id": id})); +} + +/// A plug-in folder reports a changed module as needing approval and still loads the other files; +/// granted on its own, the changed module loads from the folder. A session without a store uses the +/// grants held by the process. +#[test] +fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { + let mut s = session(); + let id = "test.engine.permissions.folderbytes"; + let plain = "test.engine.permissions.folderplain"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); + let file = dir.0.join("asks.wasm"); + std::fs::write(&file, &first).unwrap(); + std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + let path = json!({"path": dir.0.display().to_string()}); + let r = run(&mut s, "plugin.reload", path.clone()); + assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}"); + + std::fs::write(&file, &changed).unwrap(); + let r = run(&mut s, "plugin.reload", path.clone()); + assert_eq!(r["loaded"], json!([plain]), "the other file still loads"); + let failed = r["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "{r}"); + assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}"); + let why = failed[0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}"); + let hash = vectorcraft_plugins::permissions::sha256_hex(&first); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays"); + + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"]})); + let r = run(&mut s, "plugin.reload", path); + assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}"); + run(&mut s, "plugin.remove", json!({"id": id})); + run(&mut s, "plugin.remove", json!({"id": plain})); +} + /// The host enforces what a filter was granted: without `doc.write` an output that changes the /// document is refused and nothing changes, without `doc.read` it is given no objects, and without /// `state` it is given no data kept with the document and may not write any. diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 083c78a04..41ab1569a 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -199,6 +199,11 @@ pub fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() } +/// Is `s` a SHA-256 as [`sha256_hex`] writes it: 64 lower-case hexadecimal digits? +fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + /// What the user approved for a plug-in: its id, the SHA-256 of the module they approved /// ([`sha256_hex`]) and the permissions it was granted. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -209,9 +214,10 @@ pub struct Grant { } impl Grant { - /// Does it grant every one of `wanted`? - pub fn covers(&self, wanted: &[Permission]) -> bool { - wanted.iter().all(|w| self.permissions.contains(w)) + /// Does it grant every one of `wanted` to the module with this `id` and SHA-256 `hash`? A grant + /// whose own hash is not a valid SHA-256 ([`sha256_hex`]) covers nothing. + pub fn covers(&self, id: &str, hash: &str, wanted: &[Permission]) -> bool { + self.id == id && is_sha256_hex(&self.hash) && self.hash == hash && wanted.iter().all(|w| self.permissions.contains(w)) } } @@ -281,10 +287,37 @@ mod tests { #[test] fn a_grant_covers_its_permissions_and_no_more() { - let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; - assert!(g.covers(&[Permission::DocRead])); - assert!(g.covers(&[])); - assert!(!g.covers(&[Permission::State])); - assert!(!g.covers(&[Permission::DocRead, Permission::State])); + let hash = sha256_hex(b"module"); + let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; + assert!(g.covers("a", &hash, &[Permission::DocRead])); + assert!(g.covers("a", &hash, &[])); + assert!(!g.covers("a", &hash, &[Permission::State])); + assert!(!g.covers("a", &hash, &[Permission::DocRead, Permission::State])); + } + + #[test] + fn a_grant_covers_only_the_module_that_was_approved() { + let hash = sha256_hex(b"module"); + let wanted = [Permission::DocRead]; + let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead] }; + assert!(g.covers("a", &hash, &wanted)); + assert!(!g.covers("b", &hash, &wanted), "another id"); + assert!(!g.covers("a", &sha256_hex(b"other"), &wanted), "other bytes"); + assert!(!g.covers("a", &sha256_hex(b"other"), &[]), "other bytes, even when nothing is wanted"); + } + + /// Damaged data can't stand for every module, even when the hash asked about is just as damaged. + #[test] + fn a_grant_with_an_invalid_hash_covers_nothing() { + let upper = sha256_hex(b"module").to_uppercase(); + let short = "ab".repeat(31); + let long = "ab".repeat(33); + let not_hex = "g".repeat(64); + let wide = "é".repeat(32); + for hash in ["", "00", upper.as_str(), short.as_str(), long.as_str(), not_hex.as_str(), wide.as_str()] { + let g = Grant { id: "a".into(), hash: hash.into(), permissions: vec![Permission::DocRead] }; + assert!(!g.covers("a", hash, &[Permission::DocRead]), "{hash:?}"); + assert!(!g.covers("a", hash, &[]), "{hash:?}"); + } } } diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 0302ab6be..8e4635c3f 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -38,11 +38,11 @@ pub fn revoke(id: &str) -> bool { } /// May `plugin` be installed without asking the user: does it list no permissions, or did the user -/// already grant its id every permission it lists? A module with other bytes but no new permission -/// needs no new approval. +/// already grant this very module (its id and SHA-256) every permission it lists? A module with +/// other bytes needs a new approval, even when it lists the same permissions. pub fn approved(plugin: &Plugin) -> bool { let m = plugin.manifest(); - !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions())) + !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) } /// Installs `plugin`, replacing one with the same id. @@ -125,7 +125,7 @@ pub struct FolderReport { /// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is /// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]), /// which installs only once the user agrees, and one that lists permissions the user has not -/// granted its id ([`approved`]). +/// granted this very module ([`approved`]). #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result { load_folder_where(dir, |_| Ok(()), approved) diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index d5e10d8d1..59da85c22 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -130,8 +130,8 @@ fn asking(id: &str, name: &str, permissions: &str) -> Vec { /// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash /// and each permission in plain words, with hidden characters from the manifest escaped. Cancel -/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and -/// one that asks for another does. +/// installs nothing, OK installs it; installing the same module again doesn't ask, and one with +/// other bytes does, even when it lists the same permissions, as does one that asks for another. #[test] fn installing_a_plugin_that_lists_permissions_asks_first() { let mut app = app(); @@ -165,11 +165,21 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); - // Again with the same permissions: installed without asking. + // The same module again: installed without asking. + app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert!(app.ui.dialog.is_none(), "the same bytes don't ask again"); + // Other bytes with the same permissions ask, from both ways in; Cancel keeps the one installed. let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); - app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); - assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND, "other bytes ask again"); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); + io::open_bytes(&mut app, "tidy2.wasm", &again, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "File › Open asks too"); + dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); + let new_hash = vectorcraft_plugins::permissions::sha256_hex(&again); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(new_hash), "OK records the new module's hash"); // One that asks for another permission asks again. let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 9220b1c62..037a159d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6097,8 +6097,10 @@ runs it where it is called, so the call may take minutes; `notifications/cancell A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns -an error naming the permissions needed. The grant is kept with the module's hash; a module -with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A +an error naming the permissions needed. The grant is kept with the module's hash and covers only that module: the +same bytes install again without `grant`, and a module with other bytes has `needsApproval: true` and needs a new +`grant` even when it lists the same permissions. `plugin.install` does not check that a person approved `grant`: it +trusts the client that sends it. `plugin.remove` forgets the grant. A filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..80544ad2b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -26,7 +26,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed, unless the user already granted a module with the same SHA-256. With `hash`, a module whose SHA-256 differs is refused. | | `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | | `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | @@ -131,16 +131,20 @@ capability model proposed for PhotoCraft plug-ins names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the - `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. + `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. `plugin.install` does not + check that a person approved `grant`: it trusts the client that sends it. - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the - `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that - lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a + `Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with + other bytes asks again, even when it lists the same permissions. Until it is approved, the installed module and its + grant stay as they are; once it is, the grant records the new hash. A grant whose hash is not 64 lower-case + hexadecimal digits covers nothing. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` - install only plug-ins whose permissions were granted, and report the others. + a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins + Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a + `permissions` list, or with an empty one, needs no grant. - The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 31dc47a26..4eb68b804 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1487,6 +1487,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module with other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From 80770bb5fe8b7a2ffa6a808761070f0fd0f534c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 08:55:08 -0800 Subject: [PATCH 22/38] Plug-ins: an install changes the revision with the plug-in set locked A cache keyed by registry::revision could read the new revision before the plug-in was in the set and keep the old list under it, so File > Open and document.formats left out a newly installed importer until the next install or removal. The revision now changes while install holds the write lock, and a reader that sees it waits for the set. --- crates/plugins/src/registry.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 0302ab6be..f8c1fad73 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -47,9 +47,12 @@ pub fn approved(plugin: &Plugin) -> bool { /// Installs `plugin`, replacing one with the same id. pub fn install(mut plugin: Plugin) -> Arc { + // The revision changes with the set locked, so a reader that sees the new revision then waits + // for the set and finds the plug-in. + let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); let p = Arc::new(plugin); - PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone()); + plugins.insert(p.id().to_string(), p.clone()); p } @@ -168,3 +171,27 @@ pub fn load_folder_where( } Ok(report) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A cache keyed by the revision can't store the old list under the new revision: an install + /// waiting for the set leaves the revision alone until it holds the set. + #[test] + fn an_install_changes_the_revision_only_while_it_holds_the_set() { + let plugin = Plugin::load(include_bytes!("../tests/fixtures/desaturate.wasm"), Limits::default()).unwrap(); + let id = plugin.id().to_string(); + let held = PLUGINS.read().unwrap_or_else(|e| e.into_inner()); + let before = revision(); + let installing = std::thread::spawn(move || install(plugin)); + let moved = (0..1_000_000).any(|_| { + std::thread::yield_now(); + revision() != before + }); + drop(held); + installing.join().unwrap(); + assert!(!moved, "the revision changed while the install waited for the set"); + assert!(revision() > before && remove(&id)); + } +} From 3d80b6a133355b3f35eab49228e5ee56f2df3075 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 09:13:30 -0800 Subject: [PATCH 23/38] Plug-ins: the kind-mismatch error reads correctly for importers and exporters, and a failed import leaves the open document alone A plug-in of the wrong kind now gets "is a plug-in of kind `exporter`, not an importer", with rows in every complete-message catalog. The importer failure test also covers an open, edited document. docs/plugins.md states that Open Recent does not remember Open With's importer or parameters. --- crates/engine/tests/plugin_import.rs | 32 +++++++++++++++++++++++++++- crates/plugins/src/export.rs | 2 +- crates/plugins/src/import.rs | 2 +- crates/plugins/tests/abi.rs | 6 +++++- crates/ui-egui/src/i18n/ca.tsv | 3 ++- crates/ui-egui/src/i18n/cs.tsv | 3 ++- crates/ui-egui/src/i18n/de.tsv | 3 ++- crates/ui-egui/src/i18n/el.tsv | 3 ++- crates/ui-egui/src/i18n/es.tsv | 3 ++- crates/ui-egui/src/i18n/fi.tsv | 3 ++- crates/ui-egui/src/i18n/fr.tsv | 3 ++- crates/ui-egui/src/i18n/hi.tsv | 4 ++-- crates/ui-egui/src/i18n/hu.tsv | 3 ++- crates/ui-egui/src/i18n/it.tsv | 3 ++- crates/ui-egui/src/i18n/ja.tsv | 3 ++- crates/ui-egui/src/i18n/ka.tsv | 3 ++- crates/ui-egui/src/i18n/ko.tsv | 3 ++- crates/ui-egui/src/i18n/pl.tsv | 3 ++- crates/ui-egui/src/i18n/pt-br.tsv | 3 ++- crates/ui-egui/src/i18n/ru.tsv | 3 ++- crates/ui-egui/src/i18n/sv.tsv | 4 ++-- crates/ui-egui/src/i18n/tr.tsv | 3 ++- crates/ui-egui/src/i18n/uk.tsv | 3 ++- crates/ui-egui/src/i18n/vi.tsv | 3 ++- crates/ui-egui/src/i18n/zh-hans.tsv | 3 ++- crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++- docs/plugins.md | 5 ++++- 27 files changed, 86 insertions(+), 29 deletions(-) diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index 7cb8d1a0a..7f62de2b7 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -51,6 +51,14 @@ fn objects(s: &Session) -> usize { s.doc().unwrap().doc.layers[0].children().unwrap().len() } +/// The open documents' count, the active one's index, its undo and redo steps, whether it has +/// unsaved changes, and its content. +fn state(s: &mut Session) -> (usize, Option, usize, usize, bool, Value) { + let content = s.execute("document.inspect", &json!({})).unwrap(); + let d = s.doc().unwrap(); + (s.documents().len(), s.active_index(), d.history.undo.len(), d.history.redo.len(), d.is_dirty(), content) +} + /// A fresh folder for files this test writes. fn tmp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("vc-plugin-import-{tag}-{}", std::process::id())); @@ -195,9 +203,11 @@ fn importer_names_that_do_not_fit_are_errors() { let mut s = Session::new(); echo(&mut s, "test.names", r#"["tsn"]"#); install(&mut s, &tpl::returning(&tpl::manifest("test.names.filter", "filter", "{}"), r#"{"objects":[]}"#)); + install(&mut s, &tpl::returning(&tpl::exporter("test.names.exporter", r#"["tsx"]"#, "{}"), r#"{"dataBase64":""}"#)); let cases = [ (json!({"importer": "plugin.test.names"}), "x.png", "opens .tsn files, not `x.png`"), - (json!({"importer": "plugin.test.names.filter"}), "x.tsn", "is a filter, not an importer"), + (json!({"importer": "plugin.test.names.filter"}), "x.tsn", "is a plug-in of kind `filter`, not an importer"), + (json!({"importer": "plugin.test.names.exporter"}), "x.tsn", "is a plug-in of kind `exporter`, not an importer"), (json!({"importer": "plugin.test.nothere"}), "x.tsn", "test.nothere"), (json!({"importer": 3}), "x.tsn", "`importer` must be a plug-in format id"), ]; @@ -257,6 +267,26 @@ fn failures_open_nothing() { install(&mut s, &tpl::module(&tpl::importer("test.fail.traps", r#"["tsf"]"#, "{}"), "(unreachable)", "", 1)); assert!(open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.traps"})).is_err()); assert!(s.documents().is_empty(), "nothing opened"); + + // A document that is already open keeps its tab, content and undo and redo steps. + install(&mut s, &tpl::returning(&tpl::importer("test.fail.opens", r#"["tsk"]"#, "{}"), RECT)); + install(&mut s, &tpl::module(&tpl::importer("test.fail.spins", r#"["tsf"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); + open(&mut s, "Kept.tsk", b"?", json!({})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = state(&mut s); + assert!(before.2 > 0 && before.3 > 0, "{before:?}"); + for id in cases.iter().map(|c| c.0).chain(["test.fail.traps"]) { + assert!(open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).is_err(), "{id}"); + assert_eq!(state(&mut s), before, "{id}"); + } + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.spins"}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert_eq!(state(&mut s), before, "cancelled"); } /// Cancel stops an importer between two slices of its budget, and nothing opens: here one that diff --git a/crates/plugins/src/export.rs b/crates/plugins/src/export.rs index 9ecc76277..300bd80d2 100644 --- a/crates/plugins/src/export.rs +++ b/crates/plugins/src/export.rs @@ -83,7 +83,7 @@ pub fn limits(plugin: &Plugin) -> Limits { pub fn run(plugin: &Plugin, document: &[u8], params: &Value, target: Target) -> Result { let m = plugin.manifest(); if m.kind != Kind::Exporter { - return Err(Error::Params(format!("`{}` is a {}, not an exporter", m.id, m.kind.name()))); + return Err(Error::Params(format!("`{}` is a plug-in of kind `{}`, not an exporter", m.id, m.kind.name()))); } let mut params = Value::Object(m.resolve_params(params)?); params["_context"] = json!({"mode": "export", "extension": target.extension, "name": target.name}); diff --git a/crates/plugins/src/import.rs b/crates/plugins/src/import.rs index 5a7be6a85..6229d8959 100644 --- a/crates/plugins/src/import.rs +++ b/crates/plugins/src/import.rs @@ -51,7 +51,7 @@ pub fn named(importer: &str) -> Result> { /// The error of plug-in `m`, which is not an importer, asked to import. fn not_importer(m: &crate::Manifest) -> Error { - Error::Params(format!("`{}` is a {}, not an importer", m.id, m.kind.name())) + Error::Params(format!("`{}` is a plug-in of kind `{}`, not an importer", m.id, m.kind.name())) } /// The installed importers that list extension `ext` (any case, leading dot allowed), by plug-in diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index f5b78d224..1463f9916 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -272,7 +272,11 @@ fn an_exporter_returns_its_file_with_an_export_s_budget() { // Its parameters are checked, and a filter is not an exporter. assert!(matches!(export::run(&p, b"{}", &json!({"level": "high"}), target), Err(Error::Params(_)))); let filter = Plugin::load(&ok_module("(i64.const 0)"), Limits::default()).unwrap(); - assert!(matches!(export::run(&filter, b"{}", &Value::Null, target), Err(Error::Params(_)))); + let e = export::run(&filter, b"{}", &Value::Null, target).unwrap_err(); + assert!(matches!(e, Error::Params(_)) && e.to_string().contains("is a plug-in of kind `filter`, not an exporter"), "{e}"); + let importer = Plugin::load(&build(&tpl::returning(&tpl::importer("test.reads", r#"["tst"]"#, "{}"), "{}")), Limits::default()).unwrap(); + let e = export::run(&importer, b"{}", &Value::Null, target).unwrap_err(); + assert!(e.to_string().contains("is a plug-in of kind `importer`, not an exporter"), "{e}"); } /// The echo exporter the engine and UI tests read back what they hand a plug-in with: its file is diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 953914129..5d0a001b8 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6473,7 +6473,8 @@ breakText Glyphs Glifs @msg missing `importer` (see document.formats) falta `importer` (vegeu document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` ha de ser l’id de format d’un complement (vegeu document.formats) @msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` és un {_2}, no un importador +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` és un complement del tipus `{_2}`, no un importador +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` és un complement del tipus `{_2}`, no un exportador @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}} @msg the importer returned no objects l’importador no ha retornat cap objecte @msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index dc95fe117..7366381e2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6490,7 +6490,8 @@ breakText Glyphs Glyfy @msg missing `importer` (see document.formats) chybí `importer` (viz document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` musí být ID formátu zásuvného modulu (viz document.formats) @msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ otevírá soubory {_1}, ne `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` je {_2}, ne importní zásuvný modul +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` je zásuvný modul druhu `{_2}`, ne importní zásuvný modul +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` je zásuvný modul druhu `{_2}`, ne exportní zásuvný modul @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` je importní zásuvný modul: otevřete soubor pomocí document.open {{"importer"}"}} @msg the importer returned no objects importní zásuvný modul nevrátil žádné objekty @msg an importer returns `objects` and `warnings`, not `{key}` importní zásuvný modul vrací `objects` a `warnings`, ne `{key}` diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6ae88f96e..f34b88a6b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6076,7 +6076,8 @@ breakText Glyphs Glyphen @msg missing `importer` (see document.formats) `importer` fehlt (siehe document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` muss die Format-ID eines Plug-ins sein (siehe document.formats) @msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ öffnet {_1}-Dateien, nicht `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` ist ein {_2}, kein Import-Plug-in +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` ist ein Plug-in der Art `{_2}`, kein Import-Plug-in +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` ist ein Plug-in der Art `{_2}`, kein Export-Plug-in @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` ist ein Import-Plug-in: Öffnen Sie eine Datei mit document.open {{"importer"}"}} @msg the importer returned no objects das Import-Plug-in hat keine Objekte zurückgegeben @msg an importer returns `objects` and `warnings`, not `{key}` ein Import-Plug-in gibt `objects` und `warnings` zurück, nicht `{key}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5c3f193c5..1d245a392 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6442,7 +6442,8 @@ breakText Glyphs Γλύφοι @msg missing `importer` (see document.formats) λείπει το `importer` (δείτε document.formats) @msg `importer` must be a plug-in format id (see document.formats) το `importer` πρέπει να είναι αναγνωριστικό μορφής πρόσθετου (δείτε document.formats) @msg “{shown}” opens {_1} files, not `{_2}` το «{shown}» ανοίγει αρχεία {_1}, όχι `{_2}` -@msg `{_1}` is a {_2}, not an importer το `{_1}` είναι {_2}, όχι πρόσθετο εισαγωγής +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer το `{_1}` είναι πρόσθετο τύπου `{_2}`, όχι πρόσθετο εισαγωγής +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter το `{_1}` είναι πρόσθετο τύπου `{_2}`, όχι πρόσθετο εξαγωγής @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} το `{_1}` είναι πρόσθετο εισαγωγής: ανοίξτε ένα αρχείο με document.open {{"importer"}"}} @msg the importer returned no objects το πρόσθετο εισαγωγής δεν επέστρεψε κανένα αντικείμενο @msg an importer returns `objects` and `warnings`, not `{key}` ένα πρόσθετο εισαγωγής επιστρέφει `objects` και `warnings`, όχι `{key}` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 99c7c5d0c..edeba9d58 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6432,7 +6432,8 @@ breakText Glyphs Glifos @msg missing `importer` (see document.formats) falta `importer` (consulte document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` debe ser el id de formato de un plugin (consulte document.formats) @msg “{shown}” opens {_1} files, not `{_2}` «{shown}» abre archivos {_1}, no `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` es un {_2}, no un plugin de importación +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` es un plugin de tipo `{_2}`, no un plugin de importación +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` es un plugin de tipo `{_2}`, no un plugin de exportación @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` es un plugin de importación: abra un archivo con document.open {{"importer"}"}} @msg the importer returned no objects el plugin de importación no devolvió ningún objeto @msg an importer returns `objects` and `warnings`, not `{key}` un plugin de importación devuelve `objects` y `warnings`, no `{key}` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 53103e483..eef4c97f8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6525,7 +6525,8 @@ breakText Glyphs Merkit @msg missing `importer` (see document.formats) `importer` puuttuu (katso document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` on oltava laajennuksen muototunnus (katso document.formats) @msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” avaa {_1}-tiedostoja, ei `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` on {_2}, ei tuontilaajennus +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` on lajin `{_2}` laajennus, ei tuontilaajennus +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` on lajin `{_2}` laajennus, ei vientilaajennus @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` on tuontilaajennus: avaa tiedosto komennolla document.open {{"importer"}"}} @msg the importer returned no objects tuontilaajennus ei palauttanut objekteja @msg an importer returns `objects` and `warnings`, not `{key}` tuontilaajennus palauttaa `objects` ja `warnings`, ei `{key}` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 717a8a3f6..97f74171a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6485,7 +6485,8 @@ breakText Glyphs Glyphes @msg missing `importer` (see document.formats) `importer` manquant (voir document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` doit être l’identifiant de format d’un module externe (voir document.formats) @msg “{shown}” opens {_1} files, not `{_2}` « {shown} » ouvre les fichiers {_1}, pas `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` est un {_2}, pas un module externe d’importation +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` est un module externe de type `{_2}`, pas un module externe d’importation +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` est un module externe de type `{_2}`, pas un module externe d’exportation @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` est un module externe d’importation : ouvrez un fichier avec document.open {{"importer"}"}} @msg the importer returned no objects le module externe d’importation n’a renvoyé aucun objet @msg an importer returns `objects` and `warnings`, not `{key}` un module externe d’importation renvoie `objects` et `warnings`, pas `{key}` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7a6de9d9d..9ed4c6f39 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5376,7 +5376,7 @@ @msg `{_1}` can't be edited or removed `{_1}` को संपादित या हटाया नहीं जा सकता है @msg `{_1}` has no brushes to apply `{_1}` को लागू करने के लिए कोई ब्रश नहीं है @msg `{_1}` has no symbols to place `{_1}` के पास कोई सिंबल नहीं है -@msg `{_1}` is a {_2}, not an exporter `{_1}` एक {_2} है, एक निर्यातक नहीं है +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` `{_2}` प्रकार का प्लग-इन है, निर्यातक नहीं @msg `{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} `{_1}` एक लाइव इफ़ेक्ट है: इसे effect.apply {{"effect"}"}} के साथ लागू करें @msg `{_1}` is an exporter: export with document.export {{"format"}"}} `{_1}` एक एक्सपोर्टर है: document.export {{"format"}"}} से एक्सपोर्ट करें @msg `{_1}` is built in and always listed `{_1}` बनाया गया है और हमेशा सूचीबद्ध है @@ -7969,7 +7969,7 @@ theme Appearance अपीयरेंस @msg missing `importer` (see document.formats) `importer` नहीं दिया गया (document.formats देखें) @msg `importer` must be a plug-in format id (see document.formats) `importer` किसी प्लग-इन का फ़ॉर्मेट id होना चाहिए (document.formats देखें) @msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} फ़ाइलें खोलता है, `{_2}` नहीं -@msg `{_1}` is a {_2}, not an importer `{_1}` एक {_2} है, इंपोर्टर नहीं +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` `{_2}` प्रकार का प्लग-इन है, इंपोर्टर नहीं @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` एक इंपोर्टर है: document.open {{"importer"}"}} से फ़ाइल खोलें @msg the importer returned no objects इंपोर्टर ने कोई ऑब्जेक्ट नहीं लौटाया @msg an importer returns `objects` and `warnings`, not `{key}` इंपोर्टर `objects` और `warnings` लौटाता है, `{key}` नहीं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b0b517101..03f40e14c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6474,7 +6474,8 @@ breakText Glyphs Glifák @msg missing `importer` (see document.formats) hiányzik az `importer` (lásd document.formats) @msg `importer` must be a plug-in format id (see document.formats) az `importer` egy bővítmény formátumazonosítója kell legyen (lásd document.formats) @msg “{shown}” opens {_1} files, not `{_2}` „{shown}” {_1} fájlokat nyit meg, nem `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}`: {_2}, nem importáló bővítmény +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}`: `{_2}` fajtájú bővítmény, nem importáló bővítmény +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`: `{_2}` fajtájú bővítmény, nem exportáló bővítmény @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` importáló bővítmény: nyisson meg egy fájlt a document.open {{"importer"}"}} paranccsal @msg the importer returned no objects az importáló bővítmény nem adott vissza objektumot @msg an importer returns `objects` and `warnings`, not `{key}` az importáló bővítmény `objects` és `warnings` értéket ad vissza, nem `{key}` diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b9731552d..4b09769d3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6110,7 +6110,8 @@ breakText Glyphs Glifi @msg missing `importer` (see document.formats) manca `importer` (vedi document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` deve essere l’id di formato di un plug-in (vedi document.formats) @msg “{shown}” opens {_1} files, not `{_2}` «{shown}» apre file {_1}, non `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` è un {_2}, non un plug-in di importazione +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` è un plug-in di tipo `{_2}`, non un plug-in di importazione +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` è un plug-in di tipo `{_2}`, non un plug-in di esportazione @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` è un plug-in di importazione: apri un file con document.open {{"importer"}"}} @msg the importer returned no objects il plug-in di importazione non ha restituito alcun oggetto @msg an importer returns `objects` and `warnings`, not `{key}` un plug-in di importazione restituisce `objects` e `warnings`, non `{key}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 128cf09ee..11e1b702b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6175,7 +6175,8 @@ breakText Glyphs 字形 @msg missing `importer` (see document.formats) `importer` がありません (document.formats を参照) @msg `importer` must be a plug-in format id (see document.formats) `importer` にはプラグインの形式 ID を指定してください (document.formats を参照) @msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」が開くのは {_1} ファイルで、`{_2}` ではありません -@msg `{_1}` is a {_2}, not an importer `{_1}` は {_2} で、読み込みプラグインではありません +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` は種類 `{_2}` のプラグインで、読み込みプラグインではありません +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` は種類 `{_2}` のプラグインで、書き出しプラグインではありません @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` は読み込みプラグインです: document.open {{"importer"}"}} でファイルを開いてください @msg the importer returned no objects 読み込みプラグインがオブジェクトを返しませんでした @msg an importer returns `objects` and `warnings`, not `{key}` 読み込みプラグインが返すのは `objects` と `warnings` で、`{key}` ではありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bb91ca78f..5d5cb1300 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6502,7 +6502,8 @@ font-script Japanese იაპონური @msg missing `importer` (see document.formats) `importer` აკლია (იხ. document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` უნდა იყოს პლაგინის ფორმატის ID (იხ. document.formats) @msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ ხსნის {_1} ფაილებს და არა `{_2}`-ს -@msg `{_1}` is a {_2}, not an importer `{_1}` არის {_2} და არა იმპორტის პლაგინი +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` არის `{_2}` სახის პლაგინი და არა იმპორტის პლაგინი +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` არის `{_2}` სახის პლაგინი და არა ექსპორტის პლაგინი @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` იმპორტის პლაგინია: გახსენით ფაილი document.open {{"importer"}"}}-ით @msg the importer returned no objects იმპორტის პლაგინმა ობიექტები არ დააბრუნა @msg an importer returns `objects` and `warnings`, not `{key}` იმპორტის პლაგინი აბრუნებს `objects`-ს და `warnings`-ს და არა `{key}`-ს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4c93a520d..fa09154e6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6071,7 +6071,8 @@ breakText Glyphs 글리프 @msg missing `importer` (see document.formats) `importer`가 없습니다(document.formats 참조) @msg `importer` must be a plug-in format id (see document.formats) `importer`는 플러그인의 형식 ID여야 합니다(document.formats 참조) @msg “{shown}” opens {_1} files, not `{_2}` “{shown}”은(는) {_1} 파일을 엽니다. `{_2}`은(는) 열지 않습니다 -@msg `{_1}` is a {_2}, not an importer `{_1}`은(는) {_2}이며 가져오기 플러그인이 아닙니다 +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}`은(는) `{_2}` 종류의 플러그인이며 가져오기 플러그인이 아닙니다 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`은(는) `{_2}` 종류의 플러그인이며 내보내기 플러그인이 아닙니다 @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}`은(는) 가져오기 플러그인입니다: document.open {{"importer"}"}}로 파일을 여세요 @msg the importer returned no objects 가져오기 플러그인이 개체를 반환하지 않았습니다 @msg an importer returns `objects` and `warnings`, not `{key}` 가져오기 플러그인은 `{key}`가 아닌 `objects`와 `warnings`를 반환합니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4467230b7..72f9f1894 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6345,7 +6345,8 @@ breakText Glyphs Glify @msg missing `importer` (see document.formats) brak `importer` (zobacz document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` musi być identyfikatorem formatu wtyczki (zobacz document.formats) @msg “{shown}” opens {_1} files, not `{_2}` „{shown}” otwiera pliki {_1}, a nie `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` to {_2}, a nie wtyczka importu +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` to wtyczka rodzaju `{_2}`, a nie wtyczka importu +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` to wtyczka rodzaju `{_2}`, a nie wtyczka eksportu @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` to wtyczka importu: otwórz plik za pomocą document.open {{"importer"}"}} @msg the importer returned no objects wtyczka importu nie zwróciła żadnych obiektów @msg an importer returns `objects` and `warnings`, not `{key}` wtyczka importu zwraca `objects` i `warnings`, a nie `{key}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3fae0ea3a..22fd661a9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6563,7 +6563,8 @@ swatch Yellow Green Verde-amarelado @msg missing `importer` (see document.formats) falta `importer` (consulte document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` deve ser o id de formato de um plug-in (consulte document.formats) @msg “{shown}” opens {_1} files, not `{_2}` “{shown}” abre arquivos {_1}, não `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` é um {_2}, não um plug-in de importação +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` é um plug-in do tipo `{_2}`, não um plug-in de importação +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` é um plug-in do tipo `{_2}`, não um plug-in de exportação @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` é um plug-in de importação: abra um arquivo com document.open {{"importer"}"}} @msg the importer returned no objects o plug-in de importação não retornou nenhum objeto @msg an importer returns `objects` and `warnings`, not `{key}` um plug-in de importação retorna `objects` e `warnings`, não `{key}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 33f826525..1193f43ef 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6579,7 +6579,8 @@ shape Open Открытый @msg missing `importer` (see document.formats) не указан `importer` (см. document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` должен быть идентификатором формата модуля (см. document.formats) @msg “{shown}” opens {_1} files, not `{_2}` «{shown}» открывает файлы {_1}, а не `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` — это {_2}, а не модуль импорта +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` является модулем вида `{_2}`, а не модулем импорта +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` является модулем вида `{_2}`, а не модулем экспорта @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` — модуль импорта: откройте файл с помощью document.open {{"importer"}"}} @msg the importer returned no objects модуль импорта не вернул ни одного объекта @msg an importer returns `objects` and `warnings`, not `{key}` модуль импорта возвращает `objects` и `warnings`, а не `{key}` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ee1822273..85ed00f85 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6643,7 +6643,7 @@ digits Hindi Hindi @msg {what} points outside memory {what} pekar utanför minnet @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} skulle skrivas ut på fler än {MAX_TILES} rutor: skala ned det @msg `{_1}` can't be edited or removed `{_1}` kan inte redigeras eller tas bort -@msg `{_1}` is a {_2}, not an exporter `{_1}` är av typen {_2}, inte en exportfunktion +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` är ett plugin av typen `{_2}`, inte en exportfunktion @msg `{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} `{_1}` är en live-effekt: använd den med effect.apply {{"effect"}"}} @msg `{_1}` is an exporter: export with document.export {{"format"}"}} `{_1}` är en exportfunktion: exportera med document.export {{"format"}"}} @msg `{_1}` is built in and always listed `{_1}` är inbyggd och listas alltid @@ -7977,7 +7977,7 @@ digits Hindi Hindi @msg missing `importer` (see document.formats) `importer` saknas (se document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` måste vara ett formatid för ett plugin (se document.formats) @msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” öppnar {_1}-filer, inte `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` är av typen {_2}, inte en importfunktion +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` är ett plugin av typen `{_2}`, inte en importfunktion @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` är en importfunktion: öppna en fil med document.open {{"importer"}"}} @msg the importer returned no objects importfunktionen returnerade inga objekt @msg an importer returns `objects` and `warnings`, not `{key}` en importfunktion returnerar `objects` och `warnings`, inte `{key}` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index fb4269e2f..0d2960564 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6447,7 +6447,8 @@ breakText Glyphs Glifler @msg missing `importer` (see document.formats) `importer` eksik (bkz. document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` bir eklentinin biçim kimliği olmalıdır (bkz. document.formats) @msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} dosyalarını açar, `{_2}` dosyasını değil -@msg `{_1}` is a {_2}, not an importer `{_1}` bir {_2}, içe aktarma eklentisi değil +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}`, `{_2}` türünde bir eklentidir, içe aktarma eklentisi değildir +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`, `{_2}` türünde bir eklentidir, dışa aktarma eklentisi değildir @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` bir içe aktarma eklentisidir: dosyayı document.open {{"importer"}"}} ile açın @msg the importer returned no objects içe aktarma eklentisi hiç nesne döndürmedi @msg an importer returns `objects` and `warnings`, not `{key}` içe aktarma eklentisi `{key}` değil, `objects` ve `warnings` döndürür diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ac74a7404..18475a371 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6090,7 +6090,8 @@ breakText Glyphs Гліфи @msg missing `importer` (see document.formats) не вказано `importer` (див. document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` має бути ідентифікатором формату модуля (див. document.formats) @msg “{shown}” opens {_1} files, not `{_2}` «{shown}» відкриває файли {_1}, а не `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` - це {_2}, а не модуль імпорту +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` є модулем виду `{_2}`, а не модулем імпорту +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` є модулем виду `{_2}`, а не модулем експорту @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` - модуль імпорту: відкрийте файл за допомогою document.open {{"importer"}"}} @msg the importer returned no objects модуль імпорту не повернув жодного об’єкта @msg an importer returns `objects` and `warnings`, not `{key}` модуль імпорту повертає `objects` і `warnings`, а не `{key}` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 04c59cf6b..7491f1c22 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6091,7 +6091,8 @@ breakText Glyphs Glyph @msg missing `importer` (see document.formats) thiếu `importer` (xem document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` phải là id định dạng của một plug-in (xem document.formats) @msg “{shown}” opens {_1} files, not `{_2}` “{shown}” mở tệp {_1}, không phải `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` là {_2}, không phải plug-in nhập +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` là plug-in loại `{_2}`, không phải plug-in nhập +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` là plug-in loại `{_2}`, không phải plug-in xuất @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` là plug-in nhập: hãy mở tệp bằng document.open {{"importer"}"}} @msg the importer returned no objects plug-in nhập không trả về đối tượng nào @msg an importer returns `objects` and `warnings`, not `{key}` plug-in nhập trả về `objects` và `warnings`, không phải `{key}` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 188fafbac..3896cb184 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6331,7 +6331,8 @@ breakText Glyphs 字形 @msg missing `importer` (see document.formats) 缺少 `importer` (参见 document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` 必须是增效工具的格式 ID (参见 document.formats) @msg “{shown}” opens {_1} files, not `{_2}` “{shown}” 打开的是 {_1} 文件,不是 `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` 是 {_2},不是导入增效工具 +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` 是 `{_2}` 类型的增效工具,不是导入增效工具 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` 是 `{_2}` 类型的增效工具,不是导出增效工具 @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是导入增效工具:请用 document.open {{"importer"}"}} 打开文件 @msg the importer returned no objects 导入增效工具未返回任何对象 @msg an importer returns `objects` and `warnings`, not `{key}` 导入增效工具返回 `objects` 和 `warnings`,不是 `{key}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0e9326ec5..a4d3cbf72 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6128,7 +6128,8 @@ breakText Glyphs 字符 @msg missing `importer` (see document.formats) 未提供 `importer` (請參閱 document.formats) @msg `importer` must be a plug-in format id (see document.formats) `importer` 必須是增效模組的格式 ID (請參閱 document.formats) @msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」開啟的是 {_1} 檔案,不是 `{_2}` -@msg `{_1}` is a {_2}, not an importer `{_1}` 是 {_2},不是匯入增效模組 +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` 是 `{_2}` 類型的增效模組,不是匯入增效模組 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` 是 `{_2}` 類型的增效模組,不是匯出增效模組 @msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是匯入增效模組:請用 document.open {{"importer"}"}} 開啟檔案 @msg the importer returned no objects 匯入增效模組未傳回任何物件 @msg an importer returns `objects` and `warnings`, not `{key}` 匯入增效模組傳回 `objects` 和 `warnings`,不是 `{key}` diff --git a/docs/plugins.md b/docs/plugins.md index eb4537e17..9bae546a8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -69,7 +69,10 @@ extensions with that importer (on the web, File › Open's picker opens instead) desktop the importer runs off the UI thread, and a run that takes a while shows a progress window with Cancel, which stops it. An importer with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open -button opens the file; File › Open runs an importer with its parameters' defaults. **File › Open** of a `.wasm` file installs +button opens the file; File › Open runs an importer with its parameters' defaults. Open Recent opens a file the way +File › Open does: it does not remember the importer or the parameters given to Open With. A built-in format reads the +file if one can, and otherwise the first importer by id that lists its extension reads it with its parameters' defaults. +**File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). From 054c14a8191de3b8b012705e81612a065430ab9d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 09:38:30 -0800 Subject: [PATCH 24/38] Plug-ins: a file only an importer reads opens when dropped on the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Place reads only built-in formats, so a dropped file that only an importer reads failed with a place error. It now opens as File › Open opens it, with or without a drop position, also when an exporter lists the same extension. --- crates/engine/src/cmd/fileio.rs | 4 +-- crates/engine/src/cmd/fileio/formats.rs | 7 +++++ .../src/dialogs/tests_plugin_import.rs | 26 +++++++++++++++++++ crates/ui-egui/src/place.rs | 6 ++--- docs/plugins.md | 2 +- 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 4fda8e1ca..4e3cec17a 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -132,8 +132,8 @@ use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, export_extensions, extension, file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, - open_filters, opens_as_other, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, - writable_format, + open_filters, opens_as_other, opens_with_importer, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, + titled_for_export, writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 3ecb456f6..7d646afd6 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -669,6 +669,13 @@ fn format_filters() -> impl Iterator bool { + let ext = extension(name); + !ext.is_empty() && !PLACE_EXTS.contains(&ext.as_str()) && !vectorcraft_plugins::import::for_extension(&ext).is_empty() +} + /// Whether File › Open opens a file of extension `ext` (lower case) as something other than a /// document: a plug-in (installed), a shortcut set, a script (checked), flattener, PDF, print, /// perspective grid or export presets (imported), a Libraries panel library, or a swatch or graphic diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index 481d1eebf..a6adadb63 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -191,6 +191,32 @@ fn export_as_lists_exporters_and_not_importers() { app.run("plugin.remove", json!({"id": "test.ui.writes"})).unwrap(); } +/// A file only an importer reads opens wherever it is dropped, also when an exporter lists its +/// extension; a file File › Place reads is still placed on the canvas. +#[test] +fn a_dropped_file_only_an_importer_reads_opens() { + use crate::place::DropTarget; + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0))); + let on_canvas = Some(egui::pos2(400.0, 300.0)); + let placed = |app: &VectorcraftApp, name, pos| matches!(app.drop_target(name, pos, false), DropTarget::Place(_)); + assert!(placed(&app, "a.tud", on_canvas), "no importer lists the extension yet"); + + install(&mut app, &tpl::module(&tpl::importer("test.ui.dropped", r#"["tud","tus","png"]"#, "{}"), tpl::ECHO, "", 1)); + install(&mut app, &tpl::module(&tpl::exporter("test.ui.dropshare", r#"["tus"]"#, "{}"), tpl::ECHO, "", 1)); + for name in ["a.tud", "A.TUD", "a.tus"] { + assert_eq!(app.drop_target(name, on_canvas, false), DropTarget::Open, "{name} on the canvas"); + assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name} without a position"); + } + assert!(placed(&app, "a.png", on_canvas), "File › Place reads the extension"); + assert!(placed(&app, "a.png", None)); + + app.run("plugin.remove", json!({"id": "test.ui.dropped"})).unwrap(); + app.run("plugin.remove", json!({"id": "test.ui.dropshare"})).unwrap(); +} + /// The extensions File › Open's filters leave out of importers' (`fileio::opens_as_other`) are the /// ones File › Open installs, imports, checks or loads as something other than a document. #[test] diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index d4f4412ec..ebb42852a 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -444,12 +444,12 @@ impl VectorcraftApp { /// onto the canvas of the open document (Shift embeds it), else opened. Where the platform /// doesn't say (`None`: desktop drags carry no position), a document opens as a tab of its /// own and a picture or text is placed in the middle of the view. A library, a presets file, a - /// plug-in, a shortcut set or a script opens wherever it is dropped, as File → Open opens it - /// ([`io::opens_as`]). + /// plug-in, a shortcut set, a script or a file only an importer plug-in reads opens wherever it + /// is dropped, as File → Open opens it ([`io::opens_as`], [`fileio::opens_with_importer`]). pub fn drop_target(&self, name: &str, pos: Option, shift: bool) -> DropTarget { let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open }; let at = match pos { - _ if io::opens_as(name).is_some() => return DropTarget::Open, + _ if io::opens_as(name).is_some() || fileio::opens_with_importer(name) => return DropTarget::Open, Some(p) if !rect.contains(p) => return DropTarget::Open, Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), None if is_document(name) => return DropTarget::Open, diff --git a/docs/plugins.md b/docs/plugins.md index 9bae546a8..6aa9816f0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -48,7 +48,7 @@ one whose id sorts first opens it. `document.open {"importer": "plugin.", "p which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high, -and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are +and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens as a new document when it is dropped on the canvas. These rules are VectorCraft's own. In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload From 2ecd593e619c01cdf14f9163d749253687dec73a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 10:20:53 -0800 Subject: [PATCH 25/38] Plug-ins: a file past an importer's limit is refused from its size, without reading it document.open by path reads the first 64 KiB of a file only an importer reads to look for a built-in format. A larger file is read to the limit only when its size is within the limit, so an over-limit file costs no 128 MiB read and a built-in file over the limit is not held in memory twice. --- crates/engine/src/cmd/fileio/importer.rs | 22 ++++++++++++++++++---- crates/engine/src/cmd/fileio/tests.rs | 23 +++++++++++++++++++++++ crates/engine/tests/plugin_import.rs | 20 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/fileio/importer.rs b/crates/engine/src/cmd/fileio/importer.rs index 9fc667c4a..00dfc8d97 100644 --- a/crates/engine/src/cmd/fileio/importer.rs +++ b/crates/engine/src/cmd/fileio/importer.rs @@ -58,22 +58,36 @@ pub(super) fn pick(name: &str, bytes: &[u8], p: &Value) -> Result Result> { + /// How much of the file [`detect`] is given: more than its content checks read. + const SNIFF_BYTES: usize = 64 << 10; + let ext = extension(path); let explicit = named(p)?.is_some(); if !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) { return super::read_file(path); } - let head = super::read_file_head(path, import::MAX_INPUT_BYTES + 1)?; - if head.len() <= import::MAX_INPUT_BYTES { + let head = super::read_file_head(path, SNIFF_BYTES)?; + if head.len() < SNIFF_BYTES { return Ok(head); } if !explicit && detect(path, &head).is_some() { return super::read_file(path); } - Err(err(format!("can't open `{}`: {}", file_name(path), import::too_large()))) + let too_large = || err(format!("can't open `{}`: {}", file_name(path), import::too_large())); + let size = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len(); + if size > import::MAX_INPUT_BYTES as u64 { + return Err(too_large()); + } + // The file can grow after the size is read. + let file = super::read_file_head(path, import::MAX_INPUT_BYTES + 1)?; + if file.len() > import::MAX_INPUT_BYTES { + return Err(too_large()); + } + Ok(file) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index fca92d73f..9755cab52 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -878,3 +878,26 @@ fn a_photo_places_the_way_its_exif_orientation_says() { let red = |p: &image::Rgb| p[0] > 150 && p[1] < 80; assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); } + +/// With `importer` named, a file up to the importer's limit is read whole and a larger one is an +/// error, whatever its content. +#[test] +fn read_path_for_a_named_importer_stops_at_the_limit() { + let limit = vectorcraft_plugins::import::MAX_INPUT_BYTES as u64; + let dir = tmp_dir("read-path-limit"); + let path = dir.join("drawing.tuv"); + let name = path.to_string_lossy().into_owned(); + let named = json!({"importer": "plugin.test.notinstalled"}); + let sized = |len: u64| { + std::fs::write(&path, b"\x89PNG\r\n\x1a\n").unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(len).unwrap(); + }; + for len in [8, 64 << 10, 70_000, limit] { + sized(len); + assert_eq!(importer::read_path(&name, &named).unwrap().len() as u64, len); + } + sized(limit + 1); + let e = importer::read_path(&name, &named).unwrap_err(); + assert!(e.to_string().contains("larger than") && e.to_string().contains("drawing.tuv"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index 7f62de2b7..2abe7f320 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -316,3 +316,23 @@ fn a_file_past_the_input_limit_is_refused() { assert!(s.documents().is_empty()); let _ = std::fs::remove_dir_all(&dir); } + +/// A file past the importer's input limit whose start shows a built-in format opens as that format, +/// whatever its extension; named, the importer's limit applies to it. +#[test] +fn a_built_in_file_past_the_input_limit_still_opens() { + let dir = tmp_dir("largebuiltin"); + let mut s = Session::new(); + echo(&mut s, "test.largepng", r#"["tsp"]"#); + let path = dir.join("big.tsp"); + let mut png = std::io::Cursor::new(vec![]); + image::RgbaImage::new(2, 2).write_to(&mut png, image::ImageFormat::Png).unwrap(); + std::fs::write(&path, png.get_ref()).unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap(); + let e = s.execute("document.open", &json!({"path": path_of(&path), "importer": "plugin.test.largepng"})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert!(s.documents().is_empty()); + let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap(); + assert_eq!(r["format"], "png"); + let _ = std::fs::remove_dir_all(&dir); +} From 425cf48a18ec48eeeba8f9bf12b3b1622a40d23a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 10:38:29 -0800 Subject: [PATCH 26/38] Plug-ins: an imported document has no native PDF save setting --- crates/engine/src/cmd/fileio/importer.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/fileio/importer.rs b/crates/engine/src/cmd/fileio/importer.rs index 00dfc8d97..fde1ad270 100644 --- a/crates/engine/src/cmd/fileio/importer.rs +++ b/crates/engine/src/cmd/fileio/importer.rs @@ -118,7 +118,16 @@ pub(super) fn load(name: &str, bytes: &[u8], plugin: &Plugin, p: &Value) -> Resu board.rect = artboard_rect(bounds); } doc.title = file_name(name); - Ok(Loaded { doc, format, warnings: imported.warnings, restored: false, converted: false, preview_only: false, editing_data: false }) + Ok(Loaded { + doc, + format, + warnings: imported.warnings, + restored: false, + converted: false, + preview_only: false, + editing_data: false, + native_pdf_compatible: None, + }) } /// The artboard of art with bounds `r`: `r` within ±[`MAX_COORD`], at least 1 pt wide and high From a224331554129c126e0c80a18f67d802aebbc992 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 12:04:03 -0800 Subject: [PATCH 27/38] Plug-ins: a failed store write keeps the stored module and its grant together plugin.install wrote the grant and the module as two separate writes, and either could fail with only a log record. With a grant tied to the module's hash, the stored pair then no longer matched and the plug-in was skipped at the next start. The module is now written first and its grant only once it is there. When the grant can't be written, the store gets its old module back, or loses the new one on a first install, and a disabled plug-in stays disabled. A stored module that can't be read back is not replaced by one that needs a grant. --- crates/engine/src/cmd/plugin.rs | 13 +++- crates/engine/src/store.rs | 43 ++++++++++--- crates/engine/src/tests/plugins.rs | 81 +++++++++++++++++++++++++ crates/engine/tests/plugin_safe_mode.rs | 19 ++++++ docs/plugins.md | 4 +- 5 files changed, 147 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 90a8cc827..8e7fc717e 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -379,13 +379,20 @@ fn install(s: &mut Session, p: &Value) -> Result { let asked = permissions::names(&wanted).join(", "); return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees"))); } + // Installed plug-ins stay installed after a restart (#2541). The module is written first and its + // grant only once it is there; when the grant can't be written, the store gets the module it + // held back, so the module and grant kept there always belong together. A stored module that + // can't be read back isn't replaced by one that needs a grant. + let previous = s.stored_plugin_bytes(plugin.id()); + let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; registry::grant(g.clone()); - s.store_grant(&m.id, Some(g)); + if stored && !s.store_grant(&m.id, Some(g)) { + s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()); + stored = false; + } } - // Installed plug-ins stay installed after a restart (#2541). - let stored = s.store_plugin(plugin.id(), &bytes); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 375ca4d02..e54f8deb9 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -372,6 +372,29 @@ impl Session { self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) } + /// The module the store holds for the plug-in `id`: `None` without a folder or such a file, and + /// an error, with a log record, when the file can't be read or is larger than the module limit. + pub(crate) fn stored_plugin_bytes(&self, id: &str) -> Result>, String> { + let Some(dir) = self.plugin_dir.as_deref() else { return Ok(None) }; + let path = dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))); + if !path.exists() { + return Ok(None); + } + let max = vectorcraft_plugins::Limits::default().max_module_bytes as u64; + read_capped(&path, max).map(Some).inspect_err(|e| log::warn!("can't read the stored plug-in {}: {e}", path.display())) + } + + /// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a + /// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. + pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) { + match previous { + Some(bytes) => { + self.store_plugin(id, bytes); + } + None => self.unstore_plugin(id), + } + } + /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it /// (`None` without a plug-in folder). pub(crate) fn stored_plugin_file(&self, id: &str) -> Option { @@ -382,14 +405,15 @@ impl Session { /// other grants as they are; with none left, remove the file. Entries this version can't use /// are written back as they were, except one for `id`. A grants file that is too large, can't /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; - /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. - pub(crate) fn store_grant(&self, id: &str, grant: Option) { - let Some(dir) = &self.plugin_dir else { return }; + /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now + /// holds what was asked (`false` without a folder). + pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { + let Some(dir) = &self.plugin_dir else { return false }; let mut file = match read_grants(dir) { Ok(file) => file, Err(e) => { log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display()); - return; + return false; } }; let others = file.other.len(); @@ -399,22 +423,23 @@ impl Session { None => file.grants.remove(id).is_some(), } || file.other.len() != others; if !changed { - return; + return true; } let mut entries: Vec = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect(); entries.extend(file.other); if entries.is_empty() { take(dir, GRANTS_FILE); - return; + return true; } // The file is read back only up to its cap: written compactly when pretty-printed is larger, // and not at all when that is too. let fits = |bytes: &Vec| bytes.len() as u64 <= MAX_GRANTS_FILE; match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) { - Some(bytes) => { - put(dir, GRANTS_FILE, &bytes); + Some(bytes) => put(dir, GRANTS_FILE, &bytes), + None => { + log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)); + false } - None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)), } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 7d0983ef2..2dbcccb2b 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -690,6 +690,87 @@ fn a_stored_grant_covers_only_the_stored_module_it_recorded() { run(&mut attached, "plugin.remove", json!({"id": id})); } +/// A write to the plug-in store that fails leaves the store's module beside the grant that approved +/// it, so the old module loads at the next start: a module that can't be written doesn't update the +/// grant, and a grant that can't be written puts the old module back. On a first install, it takes +/// the new module out again. +#[test] +fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-failed-write"); + let id = "test.engine.permissions.failedwrite"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []})); + let first_data = vectorcraft_format::base64_encode(&first); + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"]}); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + let starts = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()) + }; + let block = |path: &std::path::Path| { + std::fs::remove_file(path).unwrap(); + std::fs::create_dir_all(path.join("block")).unwrap(); + }; + + // The module can't be written: the grant stays the old module's. + block(&module); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored"); + std::fs::remove_dir_all(&module).unwrap(); + std::fs::write(&module, &first).unwrap(); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write"); + + // The grants file is full: the new grant, which is longer, doesn't fit. + let cap = 1 << 20; + let mut entries: Vec = serde_json::from_slice(&recorded).unwrap(); + entries.push(json!({"id": "", "pad": ""})); + let room = cap - serde_json::to_vec(&entries).unwrap().len(); + *entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)}); + let full = serde_json::to_vec(&entries).unwrap(); + assert_eq!(full.len(), cap); + std::fs::write(&grants, &full).unwrap(); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap"); + assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back"); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write"); + + // The grants file can't be read, so nothing can be recorded in it. + block(&grants); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back"); + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable"); + + // A stored module that can't be read back isn't replaced, so a failed grant write can't lose it. + let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1; + std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap(); + block(&grants); + run(&mut s, "plugin.install", update); + assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays"); + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + std::fs::write(&module, &first).unwrap(); + + // A first install whose grant can't be written leaves no module without a grant in the store. + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(!module.exists() && !grants.exists()); + std::fs::create_dir_all(grants.join("block")).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + assert!(!module.exists(), "the store holds no module its grants don't cover"); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); +} + /// A plug-in folder reports a changed module as needing approval and still loads the other files; /// granted on its own, the changed module loads from the folder. A session without a store uses the /// grants held by the process. diff --git a/crates/engine/tests/plugin_safe_mode.rs b/crates/engine/tests/plugin_safe_mode.rs index 6923df616..fc9bfed29 100644 --- a/crates/engine/tests/plugin_safe_mode.rs +++ b/crates/engine/tests/plugin_safe_mode.rs @@ -198,6 +198,25 @@ fn installing_a_disabled_plug_in_again_turns_it_on() { let _ = std::fs::remove_dir_all(&dir); } +/// When the grant of an update can't be stored, the store keeps the old module, and it stays disabled. +#[test] +fn a_disabled_plug_in_whose_new_grant_cant_be_stored_stays_disabled() { + let _f = fresh(); + let dir = tmp_dir("reinstall-no-grant"); + let mut s = launch(&dir); + let manifest = r#"{"id":"test.sm.g","name":"Test","version":"1","kind":"filter","permissions":["doc.read"]}"#; + let module = |out: &str| vectorcraft_format::base64_encode(&wat::parse_str(tpl::returning(manifest, out)).unwrap()); + s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[]}"#), "grant": ["doc.read"]})).unwrap(); + s.execute("plugin.setEnabled", &json!({"id": "test.sm.g", "enabled": false})).unwrap(); + let grants = dir.join("grants.json"); + std::fs::remove_file(&grants).unwrap(); + std::fs::create_dir_all(grants.join("block")).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[],"error":null}"#), "grant": ["doc.read"]})).unwrap(); + assert_eq!(safe_mode::disabled().len(), 1, "the module the update could not replace stays disabled"); + safe_mode::set_disabled(vec![]); + let _ = std::fs::remove_dir_all(&dir); +} + /// A launch where the stored plug-in `id` stopped while loading: it is installed, then disabled by /// its stored file alone (as Disable and Continue saves it), and the next launch skips it. fn stored_plug_in_disabled_by_file(dir: &std::path::Path, id: &str) -> Session { diff --git a/docs/plugins.md b/docs/plugins.md index 2c581c425..0dc8f88e4 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -136,7 +136,9 @@ capability model proposed for PhotoCraft plug-ins - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the `Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with other bytes asks again, even when it lists the same permissions. Until it is approved, the installed module and its - grant stay as they are; once it is, the grant records the new hash. A grant whose hash is not 64 lower-case + grant stay as they are; once it is, the grant records the new hash. When the new module or its grant can't be + written to the `Plug-ins` folder, the folder keeps the old module and its grant, and the old module loads at the + next start. A grant whose hash is not 64 lower-case hexadecimal digits covers nothing. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than From 26804fb0c4f700d80f96c598e4a7e28c0007e965 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 12:29:27 -0800 Subject: [PATCH 28/38] Plug-ins: tests hold an importer to its budget and hostile extensions in the manifest fuzz An import's instruction and time budgets are now host limits on Limits, as a heavy filter's are, so a test can give an importer a small budget. An importer past its instruction or time limit, and a file past the input limit, return their error and leave an open document and its undo history unchanged. The manifest fuzz varies extensions (missing, dotted, mixed-case, repeated, too long and too many). --- crates/engine/tests/plugin_fuzz.rs | 64 ++++++++++++++++++++---- crates/engine/tests/plugin_import.rs | 75 +++++++++++++++++++++++----- crates/plugins/src/export.rs | 3 +- crates/plugins/src/import.rs | 2 + crates/plugins/src/runtime.rs | 6 +++ crates/plugins/tests/abi.rs | 35 +++++++++++++ 6 files changed, 161 insertions(+), 24 deletions(-) diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index ede412d69..70e1014ad 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -15,7 +15,7 @@ use proptest::prelude::*; use serde_json::{Map, Value, json}; use vectorcraft_engine::Session; use vectorcraft_plugins::wat as tpl; -use vectorcraft_plugins::{Error, Limits, Plugin}; +use vectorcraft_plugins::{Error, Kind, Limits, Plugin}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::rich_session; @@ -66,6 +66,29 @@ fn arb_junk() -> impl Strategy { }) } +/// One entry of a manifest's `extensions`: valid, dotted, mixed-case, empty or too long, or junk. +fn arb_extension() -> impl Strategy { + prop_oneof![ + 4 => "[a-z0-9]{1,16}".prop_map(Value::from), + 1 => prop::sample::select(vec![".fz", "FZ", "fZ", "a b", "", "x.y", "é", "a\u{202E}", "abcdefghijklmnopq"]).prop_map(Value::from), + 1 => "[a-z0-9]{17,32}".prop_map(Value::from), + 1 => arb_junk(), + ] +} + +/// A manifest's `extensions`: `None` leaves the key out; otherwise an empty list, up to the eight +/// allowed, too many, one repeated, or junk. +fn arb_extensions() -> impl Strategy> { + prop_oneof![ + 1 => Just(None), + 1 => Just(Some(json!([]))), + 4 => prop::collection::vec(arb_extension(), 1..=8).prop_map(|l| Some(Value::Array(l))), + 1 => prop::collection::vec(arb_extension(), 9..=12).prop_map(|l| Some(Value::Array(l))), + 1 => "[a-z0-9]{1,8}".prop_map(|e| Some(json!([e, "fz", e]))), + 1 => arb_junk().prop_map(Some), + ] +} + /// Numbers that tend to break arithmetic. fn arb_num() -> impl Strategy { prop_oneof![ @@ -583,10 +606,11 @@ proptest! { renders(&s, "after mutated modules")?; } - /// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated - /// permissions, junk of every type, control and bidirectional override characters) parse to - /// an error or to a manifest whose permissions fit its kind, and installing one with a junk - /// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden + /// Manifests with hostile `permissions`, `api`, names and `extensions` (unknown, reserved and + /// repeated permissions, junk of every type, control and bidirectional override characters, + /// extensions missing, dotted, mixed-case, repeated, too long or too many) parse to an error + /// or to a manifest whose permissions and extensions fit its kind, and installing one with a + /// junk `grant` ends as an error or an install, never a panic. A prompt never shows a hidden /// character from the name. #[test] fn hostile_manifest_permissions_never_crash( @@ -607,20 +631,38 @@ proptest! { arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress"])) ], + extensions in prop::option::weighted(0.5, arb_extensions()), ) { let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); - if matches!(kind, "exporter" | "importer") { - m["extensions"] = json!(["fz"]); + // Hostile extensions come with a valid `api` and no `permissions`, so the parse reaches + // them. The other half of the cases keep a valid list and reach the other fields. + let hostile_extensions = extensions.is_some(); + match extensions { + None if matches!(kind, "exporter" | "importer") => m["extensions"] = json!(["fz"]), + None | Some(None) => {} + Some(Some(hostile)) => m["extensions"] = hostile, + } + if hostile_extensions { + m["api"] = json!(1); + } else { + m["permissions"] = match permissions { + Some(Value::Null) | None => json!(names), + Some(junk) => junk, + }; } - m["permissions"] = match permissions { - Some(Value::Null) | None => json!(names), - Some(junk) => junk, - }; let text = m.to_string(); let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; if let Ok(man) = &parsed { prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text); + let exts = &man.extensions; + if matches!(man.kind, Kind::Exporter | Kind::Importer) { + prop_assert!((1..=8).contains(&exts.len()), "{}", text); + prop_assert!(exts.iter().all(|e| (1..=16).contains(&e.len()) && e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())), "{}", text); + prop_assert!(exts.iter().enumerate().all(|(i, e)| !exts[..i].contains(e)), "{}", text); + } else { + prop_assert!(exts.is_empty(), "{}", text); + } let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index 2abe7f320..120bcebd0 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -13,6 +13,7 @@ use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::{EngineError, Session}; use vectorcraft_plugins::wat::{self as tpl, Given}; +use vectorcraft_plugins::{Limits, Plugin}; /// An importer's output: one red rectangle from (10, 20) to (110, 70) and a warning. const RECT: &str = r#"{"objects":[{"type":"path","name":"Box","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]},"fills":[{"type":"solid","color":{"model":"rgb","r":1,"g":0,"b":0}}]}],"warnings":["2 layers merged"]}"#; @@ -20,6 +21,9 @@ const RECT: &str = r#"{"objects":[{"type":"path","name":"Box","path":{"subpaths" /// An importer's output: a horizontal line from (0, 5) to (50, 5). const LINE: &str = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,5]},{"p":[50,5]}]}]}}]}"#; +/// The `vc_run` body of an importer that never returns. +const SPINS: &str = "(loop $l (br $l)) (i64.const 0)"; + /// Installs the plug-in written in WAT `src` → its `plugin.list` entry. fn install(s: &mut Session, src: &str) -> Value { let wasm = wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}")); @@ -59,6 +63,19 @@ fn state(s: &mut Session) -> (usize, Option, usize, usize, bool, Value) { (s.documents().len(), s.active_index(), d.history.undo.len(), d.history.redo.len(), d.is_dirty(), content) } +/// Opens a document through a new importer `id`, with an undo step and a redo step → its [`state`]. +fn open_edited(s: &mut Session, id: &str) -> (usize, Option, usize, usize, bool, Value) { + install(s, &tpl::returning(&tpl::importer(id, r#"["tsk"]"#, "{}"), RECT)); + open(s, "Kept.tsk", b"?", json!({"importer": format!("plugin.{id}")})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = state(s); + assert!(before.2 > 0 && before.3 > 0, "{before:?}"); + before +} + /// A fresh folder for files this test writes. fn tmp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("vc-plugin-import-{tag}-{}", std::process::id())); @@ -269,15 +286,8 @@ fn failures_open_nothing() { assert!(s.documents().is_empty(), "nothing opened"); // A document that is already open keeps its tab, content and undo and redo steps. - install(&mut s, &tpl::returning(&tpl::importer("test.fail.opens", r#"["tsk"]"#, "{}"), RECT)); - install(&mut s, &tpl::module(&tpl::importer("test.fail.spins", r#"["tsf"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); - open(&mut s, "Kept.tsk", b"?", json!({})).unwrap(); - s.execute("select.all", &json!({})).unwrap(); - s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); - s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - let before = state(&mut s); - assert!(before.2 > 0 && before.3 > 0, "{before:?}"); + install(&mut s, &tpl::module(&tpl::importer("test.fail.spins", r#"["tsf"]"#, "{}"), SPINS, "", 1)); + let before = open_edited(&mut s, "test.fail.opens"); for id in cases.iter().map(|c| c.0).chain(["test.fail.traps"]) { assert!(open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).is_err(), "{id}"); assert_eq!(state(&mut s), before, "{id}"); @@ -294,7 +304,7 @@ fn failures_open_nothing() { #[test] fn a_cancelled_open_stops_the_importer() { let mut s = Session::new(); - install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); + install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), SPINS, "", 1)); let token = vectorcraft_doc::cancel::CancelToken::new(); token.cancel(); let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tss", b"?", json!({}))); @@ -302,18 +312,59 @@ fn a_cancelled_open_stops_the_importer() { assert!(s.documents().is_empty()); } -/// A file past the importer's input limit is not read past it, and doesn't open. +/// An importer that never returns, held to a small import budget, ends at its instruction limit +/// or at its time limit (0 ms, which has passed at the first check between two slices of the +/// budget). The open reports the limit, opens no document, and leaves an open document's content +/// and undo and redo steps as they were. +#[test] +fn an_importer_past_its_budget_opens_nothing() { + let cases = [ + ("test.budget.fuel", Limits { export_fuel: 1_000_000, ..Limits::default() }, "instruction budget"), + ("test.budget.time", Limits { export_fuel: 1_000_000_000, export_wall_time_ms: 0, ..Limits::default() }, "time budget"), + ]; + for (id, limits, _) in &cases { + let wasm = wat::parse_str(tpl::module(&tpl::importer(id, r#"["tbg"]"#, "{}"), SPINS, "", 1)).unwrap(); + vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()); + } + let mut s = Session::new(); + for (id, _, why) in &cases { + let e = open(&mut s, "x.tbg", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err(); + assert!(e.to_string().contains(why) && e.to_string().contains("x.tbg"), "{id}: {e}"); + assert!(s.documents().is_empty(), "{id}"); + } + let before = open_edited(&mut s, "test.budget.opens"); + for (id, _, why) in &cases { + let e = open(&mut s, "x.tbg", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err(); + assert!(e.to_string().contains(why), "{id}: {e}"); + assert_eq!(state(&mut s), before, "{id}"); + } + for (id, _, _) in &cases { + vectorcraft_plugins::registry::remove(id); + } +} + +/// A file past the importer's input limit is not read past it, and doesn't open: an open document +/// keeps its content and its undo and redo steps. Given as data, it is refused the same way. #[test] fn a_file_past_the_input_limit_is_refused() { let dir = tmp_dir("large"); let mut s = Session::new(); echo(&mut s, "test.large", r#"["tsb"]"#); let path = dir.join("big.tsb"); - std::fs::File::create(&path).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap(); + let max = vectorcraft_plugins::import::MAX_INPUT_BYTES; + std::fs::File::create(&path).unwrap().set_len(max as u64 + 1).unwrap(); let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err(); assert!(e.to_string().contains("larger than"), "{e}"); assert!(e.to_string().contains("big.tsb"), "{e}"); assert!(s.documents().is_empty()); + + let before = open_edited(&mut s, "test.large.opens"); + let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert_eq!(state(&mut s), before, "by path"); + let e = open(&mut s, "big.tsb", &vec![0; max + 1], json!({})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert_eq!(state(&mut s), before, "as data"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/plugins/src/export.rs b/crates/plugins/src/export.rs index 300bd80d2..b51fcfdb9 100644 --- a/crates/plugins/src/export.rs +++ b/crates/plugins/src/export.rs @@ -75,7 +75,8 @@ pub struct Target<'a> { /// The limits of an export by `plugin`: its own, with the budgets of an export. pub fn limits(plugin: &Plugin) -> Limits { - Limits { fuel_base: FUEL, wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() } + let own = plugin.limits(); + Limits { fuel_base: own.export_fuel, wall_time_ms: own.export_wall_time_ms, ..own.clone() } } /// Runs exporter `plugin` on `document`, the native file's JSON, with `params` (checked against diff --git a/crates/plugins/src/import.rs b/crates/plugins/src/import.rs index 6229d8959..599470e0a 100644 --- a/crates/plugins/src/import.rs +++ b/crates/plugins/src/import.rs @@ -143,6 +143,8 @@ mod tests { assert!(out.objects.iter().all(|o| o.id.is_none() && o.path.is_some())); assert_eq!(decode(format!(r#"{{"objects":[{SQUARE}]}}"#).as_bytes()).unwrap().warnings, Vec::::new()); assert_eq!(decode(br#"{"error": "Not a\nplot file."}"#), Err(Error::Failed("Not aplot file.".into()))); + // A decline is reported as one, also with keys the host doesn't read beside it. + assert_eq!(decode(br#"{"error": "No.", "layers": []}"#), Err(Error::Failed("No.".into()))); } /// Nothing an importer returns reaches a document unchecked: malformed JSON, no objects, keys diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 7059aed15..9a81f4f35 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -61,6 +61,10 @@ pub struct Limits { /// raise. Set them to `fuel_base` and `wall_time_ms` to give heavy filters nothing more. pub heavy_fuel: u64, pub heavy_wall_time_ms: u64, + /// What one export or import is given instead of `fuel_base` and `wall_time_ms` + /// ([`crate::export::limits`]). + pub export_fuel: u64, + pub export_wall_time_ms: u64, } impl Default for Limits { @@ -75,6 +79,8 @@ impl Default for Limits { wall_time_ms: 60_000, heavy_fuel: HEAVY_FUEL, heavy_wall_time_ms: HEAVY_WALL_TIME_MS, + export_fuel: crate::export::FUEL, + export_wall_time_ms: crate::export::WALL_TIME_MS, } } } diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 1463f9916..1d8e64dce 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -279,6 +279,41 @@ fn an_exporter_returns_its_file_with_an_export_s_budget() { assert!(e.to_string().contains("is a plug-in of kind `importer`, not an exporter"), "{e}"); } +/// An importer runs on an import's budget, which replaces its own instruction and time budgets and +/// keeps its other limits: a run that needs more than `fuel_base` finishes within `export_fuel`, +/// and a run that needs more than `export_fuel` ends there, however large `fuel_base` is. +#[test] +fn an_importer_runs_on_an_import_s_budget() { + use vectorcraft_plugins::export; + use vectorcraft_plugins::import::{self, Source}; + let run = format!( + "(local $n i32) (local.set $n (i32.const 100000)) \ + (loop $work (local.set $n (i32.sub (local.get $n) (i32.const 1))) (br_if $work (local.get $n))) {}", + tpl::ECHO + ); + let wasm = build(&tpl::module(&tpl::importer("test.import.budget", r#"["tst"]"#, "{}"), &run, "", 1)); + let file = br#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1,0]}]}]}}]}"#; + let source = Source { extension: "tst", name: "a" }; + + let limits = import::limits(&Plugin::load(&wasm, Limits::default()).unwrap()); + assert_eq!((limits.fuel_base, limits.wall_time_ms), (export::FUEL, export::WALL_TIME_MS)); + let own = Limits { + fuel_base: 9_000, + fuel_per_byte: 0, + wall_time_ms: 8_000, + export_fuel: 10_000_000, + export_wall_time_ms: 7_000, + max_output_bytes: 6_000, + ..Limits::default() + }; + let p = Plugin::load(&wasm, own.clone()).unwrap(); + assert_eq!(import::limits(&p), Limits { fuel_base: 10_000_000, wall_time_ms: 7_000, ..own }); + assert_eq!(import::run(&p, file, &Value::Null, source).map(|i| i.objects.len()), Ok(1)); + + let p = Plugin::load(&wasm, Limits { fuel_base: 10_000_000, fuel_per_byte: 0, export_fuel: 9_000, ..Limits::default() }).unwrap(); + assert_eq!(import::run(&p, file, &Value::Null, source), Err(Error::Limit("instruction budget".into()))); +} + /// The echo exporter the engine and UI tests read back what they hand a plug-in with: its file is /// its input, or its parameters, byte for byte, whatever their length modulo three. #[test] From 36bca85879e0b0b489bb7a43d2cdaee55916fda1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 12:59:17 -0800 Subject: [PATCH 29/38] =?UTF-8?q?Plug-ins:=20app.open=20passes=20params=20?= =?UTF-8?q?to=20the=20importer=20File=20=E2=80=BA=20Open=20picks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.open with params and no importer ran file.open with the path alone, so the desktop app ran the importer on its defaults while headless app.open passed the params to document.open. file.open now carries params to the importer that opens the file. The importer parameters test installs its module with replace from the second pass on, as plugin.install requires. The permission table in docs/plugins.md lists doc.read for filter, effect and exporter. --- crates/engine/tests/plugin_import.rs | 5 +++-- crates/ui-egui/src/control.rs | 9 ++++++++- .../src/dialogs/tests_plugin_import.rs | 8 ++++++++ crates/ui-egui/src/io.rs | 2 +- crates/ui-egui/src/io/open.rs | 19 +++++++++++++++++-- crates/ui-egui/src/menus/run.rs | 5 ++++- crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/control-protocol.md | 2 +- docs/plugins.md | 2 +- 9 files changed, 44 insertions(+), 10 deletions(-) diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index 120bcebd0..a75b4666a 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -249,9 +249,10 @@ fn the_importer_is_given_its_parameters_and_the_file_s_name() { let mut s = Session::new(); let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; let needles = [r#""mode":"import""#, r#""extension":"tsc""#, r#""name":"Shapes""#, r#""density":10.0"#]; - for needle in needles { + for (i, needle) in needles.into_iter().enumerate() { // RECT when the parameters hold the needle; the file, which isn't JSON, otherwise. - install(&mut s, &tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT)); + let wasm = wat::parse_str(tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT)).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "replace": i > 0})).unwrap(); let r = open(&mut s, "Shapes.tsc", b"not json", json!({"params": {"density": 12}})); assert_eq!(r.map(|r| r["format"].clone()).map_err(|e| e.to_string()), Ok(json!("plugin.test.params")), "{needle}"); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 203fd46a3..7252cc46d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -449,7 +449,14 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques let params = p.get("params").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!({})); wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer, "params": params}))) } - None => wrap(app.run("file.open", json!({"path": s("path")}))), + // `params` go to the importer File › Open picks for the file, as in `document.open`. + None => { + let mut open = json!({"path": s("path")}); + if let Some(params) = p.get("params").filter(|v| !v.is_null()) { + open["params"] = params.clone(); + } + wrap(app.run("file.open", open)) + } }, "app.save" | "app.export" => { let mut p = if p.is_object() { p.clone() } else { json!({}) }; diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index a6adadb63..63e97d86a 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -142,6 +142,14 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { assert!(r.get("error").is_some(), "the defaults (density 4) don't open the file: {r}"); assert_ne!(kind(&app), Some(plugin::IMPORT_KIND)); assert_eq!(app.session.documents().len(), 3); + + // Without `importer`, `params` go to the importer File › Open picks for the file. + let other = dir.join("b.tup"); + std::fs::write(&other, "not json").unwrap(); + let other = other.to_string_lossy().into_owned(); + let r = control(&mut app, json!({"path": other, "params": {"density": 7}})); + assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}"); + assert_eq!(app.session.documents().len(), 4); app.run("plugin.remove", json!({"id": "test.ui.importparams"})).unwrap(); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index bba294f0a..91f08da1b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -24,7 +24,7 @@ pub(crate) use export::{ pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, opened}; pub use open::{ MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, - open_path, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error, + open_path, open_path_with, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error, }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 43d6ef6cb..aa61d42a8 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -13,6 +13,11 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft" /// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for /// a document opened now (its `warnings` say what didn't come in as it was), else null. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result { + open_bytes_with(app, name, bytes, path, &Value::Null) +} + +/// [`open_bytes`], a document opening with `document.open`'s params `p`. +fn open_bytes_with(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let opens = opens_as(name); // A WebAssembly plug-in is installed. if opens == Some(OpensAs::Plugin) { @@ -53,7 +58,7 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti { return Ok(Value::Null); } - open_document(app, name, bytes, path, &Value::Null) + open_document(app, name, bytes, path, p) } /// The kinds of file File → Open installs, imports, checks, shows in the library panel or the @@ -436,9 +441,19 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result open_file(app, path, false) } +/// [`open_path`], with `params` for the importer plug-in that opens the file, if one does. +pub fn open_path_with(app: &mut VectorcraftApp, path: &str, params: &Value) -> Result { + open_file_with(app, path, false, &json!({ "params": params })) +} + /// [`open_path`]; with `background`, a document (not a file that installs, imports or asks /// first) is decoded off the UI thread ([`crate::opening::start`]). pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { + open_file_with(app, path, background, &Value::Null) +} + +/// [`open_file`], a document opening with `document.open`'s params `p`. +fn open_file_with(app: &mut VectorcraftApp, path: &str, background: bool, p: &Value) -> Result { // Automation confined to some folders reaches no file outside them, open in a tab or not. vectorcraft_engine::file_access::check_read(path)?; // File › Open, Open Recent, the Home screen, a drop and the Finder all focus a document that @@ -487,7 +502,7 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) Err(back) => bytes = back, } } - let r = open_bytes(app, path, &bytes, Some(path.to_string()))?; + let r = open_bytes_with(app, path, &bytes, Some(path.to_string()), p)?; note_recent(app, path); Ok(r) } diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 5ecc3b959..3d96ab04a 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -107,7 +107,10 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option match s("path") { - Some(path) => io::open_path(app, &path), + Some(path) => match p.get("params").filter(|v| !v.is_null()) { + Some(params) => io::open_path_with(app, &path, params), + None => io::open_path(app, &path), + }, None => io::open_dialog(app).map(|_| Value::Null), }, "file.openWith" => io::open_with(app, p), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 4f001c4bc..90ff628b6 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -28,7 +28,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.open", "Open…", "Cmd+O", - "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", + "{path?, params?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads); `params` are the parameters of the importer plug-in that opens the file", ), ( "file.openWith", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 16210d1cf..1c60b4697 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | diff --git a/docs/plugins.md b/docs/plugins.md index cb728ea17..ef214b018 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -142,7 +142,7 @@ capability model proposed for PhotoCraft plug-ins | Permission | Kinds | What it allows | |---|---|---| -| `doc.read` | all | Read the objects it is given. | +| `doc.read` | filter, effect, exporter | Read the objects it is given. | | `doc.write` | filter | Change, add and delete objects in the document. | | `state` | filter | Keep data of its own with the document (`documentData`). | | `effect.render` | effect | Change how the objects its effect is applied to are drawn. | From 4e1f7841151494ec176cbbdb530120a2b750923c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:17:51 -0800 Subject: [PATCH 30/38] Plug-ins: a module without a grant gets no settings or cache kept for its id The test for a changed module under a granted id now checks that the next start, a folder load and a refused install leave the id's settings and cache unattached. --- crates/engine/src/tests/plugins.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 61d864142..096d6e5d1 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1030,18 +1030,25 @@ fn a_grant_covers_only_the_module_it_was_given_to() { std::fs::write(&stored[0], &other).unwrap(); vectorcraft_plugins::registry::remove(id); vectorcraft_plugins::registry::revoke(id); + vectorcraft_plugins::registry::set_storage(id, None); + let no_storage = |how: &str| { + assert!(vectorcraft_plugins::registry::storage(id).is_none(), "{how} gives another module the id's settings and cache"); + }; let mut next = session(); next.attach_plugin_store(folder.0.clone()); assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant"); + no_storage("the next start"); let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); std::fs::write(dir.0.join("other.wasm"), &other).unwrap(); let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); assert_eq!(r["loaded"], json!([])); let why = r["failed"][0]["error"].as_str().unwrap(); assert!(why.contains("needs approval"), "{why}"); + no_storage("a folder load"); let data = vectorcraft_format::base64_encode(&other); let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string(); assert!(e.contains("`grant`"), "{e}"); + no_storage("a refused install"); // Granted, it reads the settings the first module set. run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); run(&mut next, "plugin.run", json!({"id": id, "ids": []})); From d1afb688cfe6bfab254b256abc98e43276b5f74e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:46:39 -0800 Subject: [PATCH 31/38] i18n: French rows keep the no-break space before punctuation --- crates/ui-egui/src/i18n/fr.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 12ca0da7e..726db470a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6549,13 +6549,13 @@ breakText Glyphs Glyphes @msg “{shown}” opens {_1} files, not `{_2}` « {shown} » ouvre les fichiers {_1}, pas `{_2}` @msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` est un module externe de type `{_2}`, pas un module externe d’importation @msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` est un module externe de type `{_2}`, pas un module externe d’exportation -@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` est un module externe d’importation : ouvrez un fichier avec document.open {{"importer"}"}} +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` est un module externe d’importation : ouvrez un fichier avec document.open {{"importer"}"}} @msg the importer returned no objects le module externe d’importation n’a renvoyé aucun objet @msg an importer returns `objects` and `warnings`, not `{key}` un module externe d’importation renvoie `objects` et `warnings`, pas `{key}` @msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads le fichier dépasse les {MAX_INPUT_BYTES} octets qu’un module externe d’importation lit @msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’importation déclare de 1 à {MAX_EXTENSIONS} `extensions` @msg only an exporter or an importer declares `extensions` seul un module externe d’exportation ou d’importation déclare `extensions` -@msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget` +@msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget` # Safe Mode Safe Mode… Mode sans échec… From 9a312e8adae1210b07a080e003ec94951cebda0d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:23:43 -0800 Subject: [PATCH 32/38] Type: type in a composite font is measured in its own document's definition - A file read for opening is laid out in the composite fonts registered then. Opening a document and reverting the front one now measure again the type set in a font named like a composite font that an open document defines, the font database holds or the session registered earlier. Type in other fonts, missing ones included, keeps the bounds it was read with. - Switching to a document or closing the front one measures again the type set in a composite font whose definition changed, and any type without bounds. - A document reverted while in the back measures its type in composite fonts when it comes to the front, not in the front document's. - Text holding an inline graphic whose symbol's type was measured again takes the graphic's new size. - A failed or panicked command puts the font database's composite fonts back with the documents. - Setting a composite font to the definition an export lends keeps it after the export ends. --- crates/engine/src/cmd/compositefonts.rs | 27 +++- crates/engine/src/cmd/compositefonts/tests.rs | 129 ++++++++++++++++ crates/engine/src/lib.rs | 3 + crates/engine/src/session/documents.rs | 142 +++++++++++++----- crates/engine/src/session/execute.rs | 3 + crates/text/src/fontdb/composite.rs | 18 ++- crates/text/src/tests/composite.rs | 7 + 7 files changed, 286 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 2b356ab85..887e90515 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -71,24 +71,39 @@ pub fn specs() -> Vec { /// other open documents (or an earlier active document) defined. Run after each command and when /// another document takes the front, so an undo, a revert, an opened or closed file or a document /// switch draws type in the active document's own composite fonts, and type naming one it does -/// not define shows as missing. Whether a composite font was added, changed or removed. -pub(crate) fn sync(s: &mut Session) -> bool { +/// not define shows as missing. The lowercased names of the composite fonts added, changed or +/// removed. +pub(crate) fn sync(s: &mut Session) -> Vec { let db = FontDb::global(); let mut names = Vec::new(); - let mut changed = false; + let mut changed = Vec::new(); for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() { - changed |= db.set_composite_font(&f); - names.push(f.name.to_lowercase()); + let name = f.name.to_lowercase(); + if db.set_composite_font(&f) { + changed.push(name.clone()); + } + names.push(name); } let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); let stale: Vec = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect(); for gone in stale { - changed |= db.remove_composite_font(&gone); + if db.remove_composite_font(&gone) { + changed.push(gone); + } } + s.composite_names_registered.extend(names.iter().chain(&changed).cloned()); s.composite_names = names; changed } +/// The lowercased names of the composite fonts the open documents define, the font database holds +/// and the session has registered before: the names type may have been laid out in. +pub(crate) fn names_in_use(s: &Session) -> std::collections::HashSet { + let defined = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let held = FontDb::global().composite_font_names().into_iter().map(|n| n.to_lowercase()); + defined.chain(held).chain(s.composite_names_registered.iter().cloned()).collect() +} + /// Leave out a file's unusable composite fonts before registering and measuring its type. pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) { let db = FontDb::global(); diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index 670a85ee2..ae8bc5f8b 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -395,6 +395,135 @@ fn opened_document_measures_missing_composite_font_as_missing() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// The native file of a document whose composite font `name` sets Roman at full size, with type +/// "Ab12" set in it → (the file, the type's id, its width). +fn full_roman_file(name: &str) -> (Vec, u64, f64) { + let mut s = session(); + let mut full = font(name); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": name})).unwrap()["id"].as_u64().unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + (vectorcraft_format::save_file(&s.doc().unwrap().doc), id, width) +} + +/// Type in a missing font that no composite font is named after keeps the bounds it was measured +/// with when its file was read: opening the document does not lay it out again. +#[test] +fn opening_leaves_type_in_an_unrelated_missing_font_as_measured() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Unrelated")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "No Such Family CF"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let kept = vectorcraft_geom::Rect::new(1.0, 2.0, 3.0, 4.0); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = Some(kept); + } + s.add_document(doc, None); + assert_eq!(text(&s, id).cached_bounds, Some(kept)); +} + +/// Text holding an inline graphic whose symbol's art is type in a composite font is measured with +/// the graphic at the size the opened document's own definition gives it. +#[test] +fn opening_measures_inline_graphics_of_composite_type_in_its_own_definition() { + let mut s = session(); + let mut full = font("CF Inline"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let art = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Inline"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Inline Mark", "ids": [art]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Inline Mark"})).unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Inline")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("inline.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - width).abs() < 1e-6); +} + +/// Type read for opening while a composite font of its name was registered is measured again when +/// it opens, even after the document that defined the font has closed in between. +#[test] +fn opening_measures_type_read_in_a_composite_font_closed_since() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Gone"})).unwrap()["id"].as_u64().unwrap(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Gone")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("gone.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + assert!(s.close_document(1)); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file read for opening lays out its type in the composite fonts registered then (the front +/// document's, which defines the name with Roman at half size); once the file opens, its type is +/// measured again in its own. +#[test] +fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Opened"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Opened")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("opened.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A document replaced while in the back (File › Revert) is measured in its own composite fonts +/// when it comes to the front, not in the front document's. +#[test] +fn a_document_replaced_in_the_back_is_measured_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Replaced"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.set_active(0)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// So is one that comes to the front because the front document closes. +#[test] +fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() { + let (bytes, id, width) = full_roman_file("CF Closed"); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Closed")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.close_document(1)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A failed batch takes back the composite fonts it set, in the font database too. +#[test] +fn a_failed_batch_takes_back_its_composite_fonts() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let steps = json!([ + {"command": "type.compositeFonts.set", "params": {"fonts": [font("CF Failed Batch")]}}, + {"command": "no.such.command"}, + ]); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Failed Batch").is_none()); +} + /// A loaded composite font cannot replace an installed family used by existing type. #[test] fn loaded_composite_font_cannot_take_an_installed_family_name() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 27972ef20..03082e7f5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -175,6 +175,9 @@ pub struct Session { /// The composite fonts (lowercase names) that the open documents defined when the font /// database was last brought in step with them ([`cmd::compositefonts::sync`]). pub(crate) composite_names: Vec, + /// Every composite font (lowercase name) this session has registered in the font database: + /// type read for opening may have been laid out in one that is gone by the time it opens. + pub(crate) composite_names_registered: std::collections::HashSet, /// While a command that changes nothing in the document runs (a query, or one of /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol /// instance's art. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b0619c992..20fd05355 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,3 +1,7 @@ +use std::collections::HashSet; + +use vectorcraft_doc::{Node, TextObject}; + use super::{ Arc, Clipboard, Color, CommandInfo, DocState, Document, DrawMode, EngineError, GradientPaint, Journal, NodeKind, PaintDefaults, Prefs, Result, Session, ViewInfo, cmd, command_specs, file_access, @@ -84,6 +88,7 @@ impl Session { batch_steps: None, batch_paint_docs: Vec::new(), composite_names: Vec::new(), + composite_names_registered: HashSet::new(), reading: false, place_hand_off: None, queue_hand_off: None, @@ -182,10 +187,8 @@ impl Session { self.reset_tool_for_doc_switch(); } self.active = Some(index); - // Type is drawn in the composite fonts of the document now in front, and measured again - // when they differ from those of the document before. - if switched && cmd::compositefonts::sync(self) { - self.refresh_document_text_bounds(index); + if switched { + self.sync_front_composite_fonts(); } true } else { @@ -193,6 +196,16 @@ impl Session { } } + /// Register the composite fonts of the document now in front ([`cmd::compositefonts::sync`]), + /// after a switch or a close brought it there. Its type set in a composite font that differs + /// from the one of that name before (added, changed or removed) is measured again, and type not + /// measured yet (in a document replaced while in the back) is measured now. + fn sync_front_composite_fonts(&mut self) { + let changed: HashSet = cmd::compositefonts::sync(self).into_iter().collect(); + let Some(index) = self.active else { return }; + self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &changed)); + } + /// The name [`Session::next_untitled`] gives next (New Document's Name field). pub fn peek_untitled(&self) -> String { format!("Untitled-{}", self.untitled_counter + 1) @@ -211,19 +224,44 @@ impl Session { pub(crate) fn refresh_text_bounds(doc: &mut Document) { // Inline graphics in text take their size from their symbols' art (not saved either). doc.resolve_inline_art(); - let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); - let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); - for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { - refresh_tree_text_bounds(tree); + let mut uncached = |t: &TextObject| t.cached_bounds.is_none(); + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame); } } - /// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an - /// edit: a saved document stays saved, and its undo history keeps its documents as they are. - fn refresh_document_text_bounds(&mut self, index: usize) { - if let Some(st) = self.docs.get_mut(index) { - st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc))); + /// Measure the type in open document `index` that `stale` picks (in its trees as + /// [`Session::refresh_text_bounds`] walks them), as a change that is not an edit: a saved + /// document stays saved, and its undo history keeps its documents as they are. The document is + /// left as it is when `stale` picks nothing. + fn refresh_document_text_bounds(&mut self, index: usize, mut stale: impl FnMut(&TextObject) -> bool) { + let Some(st) = self.docs.get_mut(index) else { return }; + if !text_trees(&st.doc).any(|n| has_text_where(n, &mut stale)) { + return; } + st.change_unsaved(|doc| { + let doc = Arc::make_mut(doc); + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame); + } + // Inline graphics take their size from their symbols' art, whose type may just have + // been measured again: the text holding one whose size changed is measured again too. + if !doc.resolve_inline_art().is_empty() { + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame); + } + } + }); + } + + /// Register the composite fonts of open document `index`, which is in front, and measure its + /// type not measured yet and its type set in a font of a name that a composite font of an open + /// document or of the font database has, or had before: that type may have been laid out in + /// another document's composite fonts (those registered while its file was read for opening). + fn sync_opened_composite_fonts(&mut self, index: usize) { + let mut names = cmd::compositefonts::names_in_use(self); + names.extend(cmd::compositefonts::sync(self)); + self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &names)); } /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, @@ -234,6 +272,8 @@ impl Session { // (importers and the native reader have seen to theirs already, with a warning). doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); + // Inline graphics in text take their size from their symbols' art (not saved). + doc.resolve_inline_art(); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); cmd::dimension::conform(&mut doc); @@ -244,8 +284,7 @@ impl Session { self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); - cmd::compositefonts::sync(self); - self.refresh_document_text_bounds(i); + self.sync_opened_composite_fonts(i); if self.depth == 0 && let Some(label) = self.explicit_undo_group.clone() { @@ -262,11 +301,26 @@ impl Session { } doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); + doc.resolve_inline_art(); doc.adopt_guides(); cmd::dimension::conform(&mut doc); + // In the back, type set in a font named like a composite font is measured when the document + // comes to the front and its own composite fonts are registered, not in the front + // document's. + let mut back_names = None; if self.active == Some(index) { // Pending tool work (typing, a drag) belongs to the content being thrown away. self.reset_tool_for_doc_switch(); + } else { + let mut names = cmd::compositefonts::names_in_use(self); + names.extend(doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + for tree in text_trees_mut(&mut doc) { + each_text_where(tree, &mut |t| t.cached_bounds.is_some() && sets_in_any(t, &names), &mut |t| { + t.cached_bounds = None; + t.cached_baselines.clear(); + }); + } + back_names = Some(names); } let old = &self.docs[index]; let mut st = DocState::new(doc, old.path.clone()); @@ -297,12 +351,10 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } - // Type in the composite fonts of a document in the back is measured again when it comes to - // the front. - if self.active == Some(index) { - cmd::compositefonts::sync(self); + match back_names { + Some(names) => self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() && !sets_in_any(t, &names)), + None => self.sync_opened_composite_fonts(index), } - self.refresh_document_text_bounds(index); true } @@ -319,7 +371,7 @@ impl Session { stash.push(old); } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; - cmd::compositefonts::sync(self); + self.sync_front_composite_fonts(); true } @@ -350,27 +402,49 @@ impl Session { } } -/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the -/// nodes on the way to such type are copied. -fn refresh_tree_text_bounds(n: &mut Arc) { - fn stale(n: &vectorcraft_doc::Node) -> bool { - matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none()) - || n.extras().mask.as_deref().is_some_and(|m| stale(&m.art)) - || n.children().is_some_and(|ch| ch.iter().any(|c| stale(c))) - } - if !stale(n) { +/// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased. +fn sets_in_any(t: &TextObject, names: &HashSet) -> bool { + !names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase())) +} + +/// The trees of `doc` that hold type: its layers, symbol definitions, graph designs and pattern +/// tiles. +fn text_trees(doc: &Document) -> impl Iterator> { + let trees = doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art)); + let trees = trees.chain(doc.graph_designs.iter().map(|g| &g.art)); + trees.chain(doc.patterns.iter().flat_map(|p| p.art.iter())) +} + +/// [`text_trees`], to change. +fn text_trees_mut(doc: &mut Document) -> impl Iterator> { + let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); + let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); + trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) +} + +/// Whether tree `n` (opacity mask art included) holds type that `pick` picks. +fn has_text_where(n: &Node, pick: &mut dyn FnMut(&TextObject) -> bool) -> bool { + matches!(&n.kind, NodeKind::Text(t) if pick(t)) + || n.extras().mask.as_deref().is_some_and(|m| has_text_where(&m.art, pick)) + || n.children().is_some_and(|ch| ch.iter().any(|c| has_text_where(c, pick))) +} + +/// Run `act` on the type in tree `n` (opacity mask art included) that `pick` picks. Only the nodes +/// on the way to such type are copied. +fn each_text_where(n: &mut Arc, pick: &mut dyn FnMut(&TextObject) -> bool, act: &mut dyn FnMut(&mut TextObject)) { + if !has_text_where(n, pick) { return; } let n = Arc::make_mut(n); if let NodeKind::Text(t) = &mut n.kind - && t.cached_bounds.is_none() + && pick(t) { - cmd::typecmd::refresh_bounds_for_first_frame(t); + act(t); } if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { - refresh_tree_text_bounds(&mut m.art); + each_text_where(&mut m.art, pick, act); } for c in n.children_mut().into_iter().flatten() { - refresh_tree_text_bounds(c); + each_text_where(c, pick, act); } } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 5ad84885f..e05b9962b 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -131,6 +131,9 @@ impl Session { let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; if r.is_err() && self.depth == 0 { self.batch_paint_docs.clear(); + // A failed or panicked command's documents are put back: type is drawn in their + // composite fonts again, not in those the command set. + cmd::compositefonts::sync(self); } let r = r?; if self.depth == 0 { diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index faee695ca..0e1d9afa8 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -31,6 +31,13 @@ impl FontDb { { let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); if map.get(&key).is_some_and(|f| **f == *font) { + // The same definition a guard lends: it stays when the guard drops. + let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(loan) = loans.get_mut(&key) + && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) + { + loan.adopted = true; + } return false; } map.insert(key, Arc::new(font.clone())); @@ -194,7 +201,7 @@ impl FontDb { } let font = Arc::new((*f).clone()); let previous = map.insert(key.clone(), font.clone()); - loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1 }); + loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false }); lent.push((key, font)); changed = true; } @@ -300,6 +307,9 @@ pub(super) struct CompositeLoan { previous: Option>, /// How many guards lend it. guards: usize, + /// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last + /// guard to drop then leaves it in place. + adopted: bool, } /// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. @@ -327,9 +337,11 @@ impl Drop for LentCompositeFonts<'_> { continue; } let previous = loan.previous.take(); + let adopted = loan.adopted; loans.remove(key); - // Given back unless a font of that name has been set or removed since. - if map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { + // Given back unless a font of that name has been set (the same definition included) + // or removed since. + if !adopted && map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { match previous { Some(p) => map.insert(key.clone(), p), None => map.remove(key), diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 370fa4557..729afa0d5 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -129,6 +129,13 @@ fn lent_composite_fonts_last_as_long_as_a_guard() { db.set_composite_font(&replaced); drop(lent); assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); + // So does the same definition set while lent, after every guard lending it drops. + assert!(db.remove_composite_font("Lent")); + let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1])); + assert!(!db.set_composite_font(&fonts[0])); + drop(one); + drop(two); + assert!(db.composite_font("Lent").is_some()); // A guard whose font was removed doesn't give back a later guard's loan of that name. assert!(db.remove_composite_font("Lent")); let old = db.lend_composite_fonts(&fonts[..1]); From 6326d1c4ffea0c67032fde591779554fa1cc8251 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 15:07:20 -0800 Subject: [PATCH 33/38] Plug-ins: the install comment states what a failed module restore leaves --- crates/engine/src/cmd/plugin.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 08b56cde7..e9f35aa7c 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -409,8 +409,9 @@ fn install(s: &mut Session, p: &Value) -> Result { } // Installed plug-ins stay installed after a restart (#2541). The module is written first and its // grant only once it is there; when the grant can't be written, the store gets the module it - // held back, so the module and grant kept there always belong together. A stored module that - // can't be read back isn't replaced by one that needs a grant. + // held back. A failed write of that module is only logged, and the grant then doesn't cover + // the new module. A stored module that can't be read back isn't replaced by one that needs a + // grant. let previous = s.stored_plugin_bytes(plugin.id()); let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); if m.asks_permissions() { From 421189893fdbc67cb096459f0f8ed15f58319186 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 15:18:31 -0800 Subject: [PATCH 34/38] Plug-ins: composite fonts stay out of importer filters; base64 size is checked first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open imports .vccomposite files as composite fonts, so an importer listing that extension no longer shows it under its own filter or in document.formats. File › Open gets a Composite fonts filter. document.open refuses dataBase64 that decodes past the importer input limit before decoding it. A file whose start shows a built-in format still opens as that format, as it does by path. --- crates/engine/src/cmd/fileio/formats.rs | 8 +++-- crates/engine/src/cmd/fileio/importer.rs | 31 ++++++++++++++++--- crates/engine/src/cmd/fileio/load.rs | 5 ++- crates/engine/src/cmd/fileio/tests.rs | 4 +-- crates/engine/tests/plugin_import.rs | 19 ++++++++++++ crates/format/src/lib.rs | 2 +- .../src/dialogs/tests_plugin_import.rs | 3 +- 7 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 3c52d5de8..dfe9c911e 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -686,8 +686,8 @@ pub fn opens_with_importer(name: &str) -> bool { /// Whether File › Open opens a file of extension `ext` (lower case) as something other than a /// document: a plug-in (installed), a shortcut set, a script (checked), flattener, PDF, print, -/// perspective grid or export presets (imported), a Libraries panel library, or a swatch or graphic -/// style library. +/// perspective grid or export presets or composite fonts (imported), a Libraries panel library, or a +/// swatch or graphic style library. pub fn opens_as_other(ext: &str) -> bool { [ crate::cmd::plugin::EXTS, @@ -698,6 +698,7 @@ pub fn opens_as_other(ext: &str) -> bool { crate::cmd::printpresets::PRESET_EXTS, crate::cmd::perspgrid::PRESET_EXTS, screens::PRESET_FILE_EXTS, + crate::cmd::compositefonts::EXTS, crate::cmd::library::LIBRARY_EXTS, crate::cmd::swatchlib::LIBRARY_EXTS, std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT), @@ -771,7 +772,7 @@ pub fn builtin_open_filters() -> impl Iterator impl Iterator { std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS)) .chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS))) @@ -779,6 +780,7 @@ fn other_open_filters() -> impl Iterator Result Result<()> { + let Some(b64) = str_param(p, "dataBase64") else { + return Ok(()); + }; + let name = str_param(p, "name").unwrap_or("Untitled"); + let ext = extension(name); + let explicit = named(p)?.is_some(); + let built_in = !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()); + if built_in || vectorcraft_format::base64_decoded_len(b64) <= import::MAX_INPUT_BYTES as u64 { + return Ok(()); + } + if !explicit { + let start: Vec = b64.bytes().filter(|b| !b.is_ascii_whitespace()).take(SNIFF_BYTES / 3 * 4).collect(); + let head = std::str::from_utf8(&start).ok().and_then(vectorcraft_format::base64_decode); + if head.is_some_and(|head| detect(name, &head).is_some()) { + return Ok(()); + } + } + Err(err(format!("can't open `{}`: {}", file_name(name), import::too_large()))) +} + /// The file at `path` for `document.open`'s params `p`. A file that only an importer reads (one /// `importer` names, or an extension no built-in format reads that an importer lists) is read up /// to [`import::MAX_INPUT_BYTES`]: past that, it is an error, unless the start of the file shows a @@ -62,9 +88,6 @@ pub(super) fn pick(name: &str, bytes: &[u8], p: &Value) -> Result Result> { - /// How much of the file [`detect`] is given: more than its content checks read. - const SNIFF_BYTES: usize = 64 << 10; - let ext = extension(path); let explicit = named(p)?.is_some(); if !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 2bc1de185..5d22630e5 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -689,7 +689,10 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result { // A file only an importer plug-in reads is read up to the importer's input limit. let src = match str_param(p, "path") { Some(path) => Source { name: path, bytes: super::importer::read_path(path, p)?, path: Some(path) }, - None => source(p, "document.open")?, + None => { + super::importer::check_data(p)?; + source(p, "document.open")? + } }; // A relative path is the file in the working directory: its folder is where relative links // are found (a bare file name has no folder of its own). diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 2e8677e58..c523284ba 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -377,8 +377,8 @@ fn open_exts_cover_every_readable_format() { assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); assert_eq!( filters.len(), - 10 + FORMATS.iter().filter(|f| f.read).count(), - "and swatch libraries, libraries, flattener, PDF, print and export presets, shortcut sets, scripts, plug-ins" + 11 + FORMATS.iter().filter(|f| f.read).count(), + "and swatch libraries, libraries, flattener, PDF, print and export presets, composite fonts, shortcut sets, scripts, plug-ins" ); assert!(filters.contains(&("Export presets", SCREEN_EXPORT_PRESET_EXTS)), "File › Open imports export presets"); assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts"); diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index a75b4666a..ddb414564 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -199,6 +199,7 @@ fn file_open_lists_only_the_extensions_it_opens_with_an_importer() { let filters: Vec<_> = fileio::open_filters().collect(); assert!(filters.contains(&("Test test.claimed", &["tcl"][..])), "{filters:?}"); assert!(!filters.iter().any(|(label, _)| *label == "Test test.claimedonly"), "{filters:?}"); + assert!(filters.contains(&("Composite fonts", vectorcraft_engine::cmd::compositefonts::EXTS)), "{filters:?}"); s.execute("plugin.remove", &json!({"id": "test.claimed"})).unwrap(); s.execute("plugin.remove", &json!({"id": "test.claimedonly"})).unwrap(); } @@ -366,6 +367,20 @@ fn a_file_past_the_input_limit_is_refused() { let e = open(&mut s, "big.tsb", &vec![0; max + 1], json!({})).unwrap_err(); assert!(e.to_string().contains("larger than"), "{e}"); assert_eq!(state(&mut s), before, "as data"); + // The size is checked before the text is decoded: this text is not base64 at all. + let not_base64 = "!".repeat((max + 1) / 3 * 4 + 8); + for extra in [json!({}), json!({"importer": "plugin.test.large"})] { + let mut p = json!({"name": "big.tsb", "dataBase64": not_base64}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let e = s.execute("document.open", &p).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert_eq!(state(&mut s), before, "before decoding"); + } + // White space is not part of the file's size. + let spaced = format!("QUJD{}", " ".repeat(max / 3 * 4 + 8)); + if let Err(e) = s.execute("document.open", &json!({"name": "small.tsb", "dataBase64": spaced})) { + assert!(!e.to_string().contains("larger than"), "{e}"); + } let _ = std::fs::remove_dir_all(&dir); } @@ -386,5 +401,9 @@ fn a_built_in_file_past_the_input_limit_still_opens() { assert!(s.documents().is_empty()); let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap(); assert_eq!(r["format"], "png"); + let mut data = png.into_inner(); + data.resize((vectorcraft_plugins::import::MAX_INPUT_BYTES + 3) / 3 * 3, 0); + let encoded = json!({"name": "big.tsp", "dataBase64": vectorcraft_format::base64_encode(&data)}); + assert_eq!(s.execute("document.open", &encoded).unwrap()["format"], "png"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index b3aa7eab7..5d7aabd07 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -56,7 +56,7 @@ use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with}; -pub use craft_vectorcraft_format::base64::{decode as base64_decode, encode as base64_encode}; +pub use craft_vectorcraft_format::base64::{decode as base64_decode, decoded_len as base64_decoded_len, encode as base64_encode}; pub use craft_vectorcraft_format::{ BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part, sniff, diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index 63e97d86a..7dd035c1e 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -230,7 +230,7 @@ fn a_dropped_file_only_an_importer_reads_opens() { #[test] fn importer_filters_leave_out_what_file_open_opens_as_something_else() { use vectorcraft_engine::cmd::{self, fileio}; - let lists: [&[&str]; 10] = [ + let lists: [&[&str]; 11] = [ cmd::plugin::EXTS, cmd::shortcutset::EXTS, cmd::scripts::EXTS, @@ -239,6 +239,7 @@ fn importer_filters_leave_out_what_file_open_opens_as_something_else() { cmd::printpresets::PRESET_EXTS, cmd::perspgrid::PRESET_EXTS, fileio::SCREEN_EXPORT_PRESET_EXTS, + cmd::compositefonts::EXTS, cmd::library::LIBRARY_EXTS, cmd::swatchlib::LIBRARY_EXTS, ]; From 4fadb4456672eaaaad85c6bf679a99ac8ea3499a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 15:33:33 -0800 Subject: [PATCH 35/38] Type: a refused composite font edit leaves no new font in the font database The session now lists the new fonts' names before the edit, so sync removes a name the document does not define when the edit is refused. --- crates/engine/src/cmd/compositefonts.rs | 3 +++ crates/engine/src/cmd/compositefonts/tests.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 887e90515..3925612a1 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -187,6 +187,9 @@ fn replace(s: &mut Session, label: &str, after: Vec) -> Result<() } let changed: Vec = before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect(); + // A refused or panicked edit leaves the document without the new fonts: `sync` forgets a name + // listed here that the document doesn't define. + s.composite_names.extend(after.iter().map(|f| f.name.to_lowercase())); let db = FontDb::global(); for f in &before { if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index ae8bc5f8b..f51f066df 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -524,6 +524,21 @@ fn a_failed_batch_takes_back_its_composite_fonts() { assert!(db.composite_font("CF Failed Batch").is_none()); } +/// A composite font whose edit is refused, because it makes selected type too wide for the canvas, +/// is not left in the font database. +#[test] +fn a_refused_edit_takes_back_its_new_composite_font() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let x = crate::MAX_COORD - 1000.0; + s.execute("text.create", &json!({"x": x, "y": 50, "text": "MMMMMMMM", "size": 100, "font": "CF Refused Edit"})).unwrap(); + let wide = json!({"name": "CF Refused Edit", "parts": {"kanji": {"font": "Source Sans 3", "size": 400}}}); + let e = s.execute("type.compositeFonts.set", &json!({"fonts": [wide]})).unwrap_err().to_string(); + assert!(e.contains("exceed the canvas"), "{e}"); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Refused Edit").is_none()); +} + /// A loaded composite font cannot replace an installed family used by existing type. #[test] fn loaded_composite_font_cannot_take_an_installed_family_name() { From 438f1cd8f251a31caac3a6716e39abacca8476b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 15:35:45 -0800 Subject: [PATCH 36/38] Plug-ins: importer tests use American spelling --- crates/engine/tests/plugin_import.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index ddb414564..71f21e10d 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -1,6 +1,6 @@ //! Importer plug-ins (`"kind": "importer"`, docs/plugins.md › Importers) through the engine: the //! formats they add to `document.formats` and `document.open`, which files they are given, the -//! document their objects become, and what happens when they fail, decline or are cancelled. +//! document their objects become, and what happens when they fail, decline or are canceled. //! //! A test binary of its own: installed plug-ins are process-wide, and the unit tests that check the //! exact list of formats would see these ones. @@ -298,13 +298,13 @@ fn failures_open_nothing() { token.cancel(); let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.spins"}))); assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); - assert_eq!(state(&mut s), before, "cancelled"); + assert_eq!(state(&mut s), before, "canceled"); } /// Cancel stops an importer between two slices of its budget, and nothing opens: here one that -/// never returns, run with a token cancelled beforehand. +/// never returns, run with a token canceled beforehand. #[test] -fn a_cancelled_open_stops_the_importer() { +fn a_canceled_open_stops_the_importer() { let mut s = Session::new(); install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), SPINS, "", 1)); let token = vectorcraft_doc::cancel::CancelToken::new(); From 83f1333d1c779223254f70407c848ae5507f23b9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:14:48 -0800 Subject: [PATCH 37/38] Effects: a Transform effect that flattens the object leaves a fitted gradient fitted with its options on With Scale Strokes & Effects or Transform Patterns on, the path and each copy are moved by Node::transform, which fixed a fitted gradient to its fit before a scale of 0 flattened it to one color. Scaling::keep_fit_when_flat keeps the gradient fitted under a map that flattens the object (node::flattens, the gate fixes_fit already used), and the Transform effect's path transforms set it. docs/target-app-parity.md also says that the Gradient tool still shows a fitted gradient's vector at its unturned fit. --- crates/doc/src/node.rs | 2 +- crates/doc/src/node/live_shape.rs | 4 +++ crates/doc/src/node/ops.rs | 15 ++++++++-- crates/doc/src/node/tests.rs | 3 +- crates/effects/src/bake/tests.rs | 40 ++++++++++++++++++--------- crates/effects/src/geometry.rs | 6 ++-- crates/effects/src/reshape.rs | 5 +++- crates/eps/src/import/native/build.rs | 3 +- docs/target-app-parity.md | 2 +- 9 files changed, 56 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 550ac3770..774e82439 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -31,7 +31,7 @@ pub use ids::{LAYER_COLORS, LayerColor, NodeId}; pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget}; pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; -pub use ops::keeps_gradient_fit; +pub use ops::{flattens, keeps_gradient_fit}; use wire::NodeWire; pub use wire::{MAX_READ_NODES, with_node_budget}; diff --git a/crates/doc/src/node/live_shape.rs b/crates/doc/src/node/live_shape.rs index 93f2022e0..9ddc4e1bb 100644 --- a/crates/doc/src/node/live_shape.rs +++ b/crates/doc/src/node/live_shape.rs @@ -329,6 +329,10 @@ pub struct Scaling { /// Pattern fills and strokes move with the art (Transform Patterns; General › Transform /// Pattern Tiles is its default): their tiles transform too, instead of staying put. pub patterns: bool, + /// Gradients fitted to the object stay fitted under a transform that flattens it to a line or + /// a point (no area left), as a Transform effect leaves them, instead of being fixed to their + /// fit and flattened to one color with the object. + pub keep_fit_when_flat: bool, } impl Scaling { diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index bc1f72e74..f908e50a1 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -431,7 +431,7 @@ impl Node { // Type and symbols in perspective keep looking the same, moved by `a`. self.transform_projection(a); // Refitting an unplaced gradient only reproduces moves and uniform scales. - if !keeps_gradient_fit(a) { + if !keeps_gradient_fit(a) && !(sc.keep_fit_when_flat && flattens(a)) { self.pin_gradients(); } let k = Scaling::factor(a); @@ -770,13 +770,24 @@ impl Node { } } -/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? +/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? The +/// terms are compared within 1e-12 of the scale (at least of 1). A scale of 0 or less, a turn, a +/// reflection and an uneven scale give false, and so does a map that flattens the object +/// ([`flattens`]), which callers that keep a fitted gradient there check apart. pub fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); let eps = 1e-12 * m0.abs().max(1.0); m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps } +/// Does `a` flatten what it maps to a line or a point (its determinant 0, within 1e-12 of the +/// squares of its terms), or is it not finite? +pub fn flattens(a: Affine) -> bool { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let norm = m0 * m0 + m1 * m1 + m2 * m2 + m3 * m3; + !a.is_finite() || a.determinant().abs() <= 1e-12 * norm +} + impl Node { /// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true /// everywhere for objects without them, such as type, whose boxes stand in). diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 43a8ac97b..335eb6312 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -221,7 +221,8 @@ fn assert_round_corners(n: &Node, back: Affine, r: f64) { } } -const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; +const KEEP_CORNERS: Scaling = + Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false, keep_fit_when_flat: false }; #[test] fn uneven_scales_keep_live_corners_circular() { diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index d42199a68..6643d91cb 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -313,20 +313,34 @@ fn a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted() { use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted, 4.0)); - n.appearance.effects = vec![fx("distort.transform", json!({"scaleH": 0.0, "rotate": 30.0}))]; - let d = bake_document(&doc_with(n)).unwrap(); - let mut found = vec![]; - d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { - for item in &c.appearance.items { - if let AppearanceItem::Stroke(s) = item - && let Paint::Gradient(g) = &s.paint - { - found.push(g.geom); - } + // With Scale Strokes & Effects or Transform Patterns on, and for each copy, as without them. + for (options, strokes) in [ + (json!({}), 1), + (json!({"strokes": true}), 1), + (json!({"patterns": true}), 1), + (json!({"copies": 2}), 3), + (json!({"strokes": true, "copies": 2}), 3), + (json!({"patterns": true, "copies": 2}), 3), + ] { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted.clone(), 4.0)); + let mut p = json!({"scaleH": 0.0, "rotate": 30.0}); + if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) { + p.extend(o.clone()); } - }); - assert_eq!(found, [None]); + n.appearance.effects = vec![fx("distort.transform", p)]; + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + found.push(g.geom); + } + } + }); + assert_eq!(found, vec![None; strokes], "{options}"); + } } #[test] diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 0d51e1d2b..ed5de8daf 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -188,11 +188,9 @@ pub struct Carry(Vec); /// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit /// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map /// that flattens the geometry to a line (a scale of 0) leaves the gradient fitted, since mapping it -/// would flatten it to one color. +/// would flatten it to one color ([`vectorcraft_doc::node::flattens`]). pub(crate) fn fixes_fit(m: vectorcraft_geom::Affine) -> bool { - let [a, b, c, d, _, _] = m.as_coeffs(); - let norm = a * a + b * b + c * c + d * d; - m.is_finite() && m.determinant().abs() > 1e-12 * norm && !vectorcraft_doc::node::keeps_gradient_fit(m) + !vectorcraft_doc::node::flattens(m) && !vectorcraft_doc::node::keeps_gradient_fit(m) } #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 5ab50dff9..441981f77 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -701,7 +701,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; } if separate { - let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room); + let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; + let made = copy_leaf(leaf, &path, m, copies, scaling, room); used = used.minus(before).plus(made); return; } @@ -771,6 +772,8 @@ fn transform_leaf( strokes: options.strokes, effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), patterns: options.patterns, + // A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does. + keep_fit_when_flat: true, ..Default::default() }; if !options.objects { diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 57d961181..39c751bfc 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -22,7 +22,8 @@ pub(super) enum Page { /// How art moved from a page to its place in the layers' document transforms: its pattern tiles /// move with it. -pub(super) const PAGE_MOVE: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true }; +pub(super) const PAGE_MOVE: Scaling = + Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true, keep_fit_when_flat: false }; /// The page (`visible`, the file read as its page) and the place of its area in the layers' /// document. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5082cb6f4..7679101f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -549,7 +549,7 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. -- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. From de6cc6a9c3d142aae2ccb27336bed0875d61b326 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Mon, 9 Nov 2026 01:26:34 +0100 Subject: [PATCH 38/38] Fix #6252: selecting an object gives new art its fill, stroke and weight With New Art Has Basic Appearance on (the default), the selection never reached the fill and stroke proxies, so new art kept the colours set last. A select.* command or layer.target that changes the selection now loads the proxies and the weight from what the selection shows in them. A paint or weight the selected objects don't share stays as it was, and art without paint (a placed image) changes nothing. The template is left alone, and only a changed selection loads, so a sampled paint stays. --- crates/engine/src/cmd/newart.rs | 34 ++++++- crates/engine/src/session/execute.rs | 6 ++ crates/engine/src/tests/newart.rs | 130 +++++++++++++++++++++++++++ crates/engine/src/tests/xform.rs | 13 +++ docs/mcp.md | 6 +- 5 files changed, 184 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/newart.rs b/crates/engine/src/cmd/newart.rs index 34db64081..c7bd4ffbf 100644 --- a/crates/engine/src/cmd/newart.rs +++ b/crates/engine/src/cmd/newart.rs @@ -5,8 +5,8 @@ //! `stroke.set` whatever is selected), a graphic style clicked with nothing selected (more fills //! and strokes, effects, opacity and blend mode, and the link to the style) or, with the Appearance //! panel's New Art Has Basic Appearance off, the whole appearance of the last selection. With the -//! option on (the default) an inherited appearance gives new art its top stroke's options only. -//! `paint.default` resets it. +//! option on (the default) an inherited appearance gives new art its top stroke's options only, +//! and a change of selection loads only the proxies. `paint.default` resets it. use std::borrow::Cow; @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "New Art Has Basic Appearance", ["Window", "Appearance"], None, - "{on?: bool (omitted: toggle)} the Appearance panel option (the preference newArtBasic, on by default): on, new art takes one fill and stroke (with the Stroke panel's options); off, the whole appearance of the last selected object (every fill, stroke and effect, opacity and blend mode) → {on}", + "{on?: bool (omitted: toggle)} the Appearance panel option (the preference newArtBasic, on by default): on, new art takes one fill and stroke (with the Stroke panel's options), the fill, stroke and weight the selection shares when it last changed; off, the whole appearance of the last selected object (every fill, stroke and effect, opacity and blend mode) → {on}", always, set_basic ), @@ -213,6 +213,34 @@ impl Session { let (ap, opacity, blend) = (super::style::captured(n), n.opacity, n.blend); self.take_new_art(ap, opacity, blend, style, true); } + + /// After a command changed the selection, with New Art Has Basic Appearance on: the fill and + /// stroke proxies and the weight take what the selection shows in them, so new art looks like + /// it. A paint the selected objects don't share (a "?" proxy) stays as it was, and art without + /// paint (a placed image, whose None would make new art invisible) changes nothing. The + /// template is left alone, and without a stroke to show the weight stays too. + pub(crate) fn load_selection_paints(&mut self) { + if !self.prefs.new_art_basic { + return; + } + let has_paint = self.proxy_source(false).is_some_and(|(n, _)| matches!(n.kind, NodeKind::Text(_)) || !n.appearance.items.is_empty()); + if !has_paint { + return; + } + let (fill_mixed, stroke_mixed) = self.proxy_mixed(); + let (fill, stroke) = self.proxy_paints(); + let weight_mixed = self.active().is_some_and(|st| st.stroke_mixed().weight); + let weight = self.shown_stroke().filter(|st| st.paint != Paint::None && !weight_mixed).map(|st| st.width); + if !fill_mixed { + self.paint.fill = unplaced(&fill); + } + if !stroke_mixed { + self.paint.stroke = unplaced(&stroke); + } + if let Some(width) = weight { + self.paint.stroke_width = width; + } + } } fn set_basic(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index a8cc8ff5a..f982121b5 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -113,6 +113,9 @@ impl Session { }) .flatten(); let selected_before = select_first.as_ref().and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer)); + // Only a selection command that changes the selection loads its paints for new art: other + // commands, and clicking what is already selected, keep a sampled paint in the proxies. + let selection_was = (id.starts_with("select.") || id == "layer.target").then(|| self.active().map(|d| d.selection.clone())).flatten(); let run = |s: &mut Session| { if let Some(ids) = &select_first { s.execute("select.set", &serde_json::json!({ "ids": ids }))?; @@ -147,6 +150,9 @@ impl Session { }); if !restored { self.inherit_new_art(); + if selection_was.is_some_and(|was| self.active().is_some_and(|d| d.selection != was)) { + self.load_selection_paints(); + } } for uid in std::mem::take(&mut self.batch_paint_docs) { if let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) diff --git a/crates/engine/src/tests/newart.rs b/crates/engine/src/tests/newart.rs index 0f41a5b9d..583a5f43d 100644 --- a/crates/engine/src/tests/newart.rs +++ b/crates/engine/src/tests/newart.rs @@ -141,6 +141,136 @@ fn a_graphic_style_clicked_with_nothing_selected_styles_the_next_object() { assert!(s.execute("graphicStyle.apply", &json!({"name": name, "ids": []})).is_err(), "explicit ids that match nothing"); } +/// A rectangle with this fill, stroke and weight, selected. +fn painted(s: &mut Session, x: f64, fill: &str, stroke: &str, weight: f64) -> NodeId { + let id = rect(s, x); + run(s, "paint.setFill", json!({"color": fill})); + run(s, "paint.setStroke", json!({"color": stroke})); + run(s, "stroke.set", json!({"weight": weight})); + id +} + +fn select(s: &mut Session, ids: &[NodeId]) { + run(s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::>()})); +} + +#[test] +fn selecting_an_object_gives_new_art_its_fill_stroke_and_weight_with_basic_appearance_on() { + let mut s = session(); + assert!(s.prefs.new_art_basic, "on by default"); + let green = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0); + painted(&mut s, 120.0, "#0000ff", "#000000", 2.0); + select(&mut s, &[green]); + run(&mut s, "select.none", json!({})); + let id = rect(&mut s, 220.0); + let (n, from) = (node(&s, id).appearance, node(&s, green).appearance); + assert_eq!((n.fill_paint(), n.stroke_paint(), n.stroke_width()), (from.fill_paint(), from.stroke_paint(), 5.0)); +} + +#[test] +fn with_basic_appearance_on_only_the_basic_fill_and_stroke_follow_the_selection() { + let mut s = session(); + let src = fancy(&mut s); + painted(&mut s, 120.0, "#00ff00", "#000000", 2.0); + select(&mut s, &[src]); + run(&mut s, "select.none", json!({})); + let id = rect(&mut s, 220.0); + let n = node(&s, id); + assert_eq!(n.appearance.fill_paint(), node(&s, src).appearance.fill_paint(), "the top fill"); + assert_eq!((n.appearance.items.len(), n.appearance.effects.len(), n.opacity), (2, 0, 1.0), "one fill and stroke, nothing else"); +} + +#[test] +fn a_paint_the_selected_objects_do_not_share_stays_as_it_was() { + let mut s = session(); + let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0); + let b = painted(&mut s, 120.0, "#0000ff", "#00ff00", 5.0); + painted(&mut s, 220.0, "#ffff00", "#000000", 1.0); + let before = s.paint.fill.clone(); + select(&mut s, &[a, b]); + assert_eq!(s.paint.fill, before, "the fills differ"); + assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (node(&s, a).appearance.stroke_paint(), 5.0), "the strokes are shared"); +} + +#[test] +fn art_without_paint_leaves_the_proxies_as_they_were() { + let mut s = session(); + painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0); + let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone()); + let dir = super::links::Folder::new("new-art-image"); + let path = dir.file("pic.png"); + super::links::write(&path, &super::links::png(4, 4, super::links::RED)); + let image = super::links::place(&mut s, &path); + run(&mut s, "select.none", json!({})); + select(&mut s, &[image]); + assert_eq!((s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width), (fill, stroke, 5.0), "new art stays visible"); +} + +#[test] +fn an_object_without_a_stroke_clears_the_stroke_and_keeps_the_weight() { + let mut s = session(); + let bare = rect(&mut s, 20.0); + run(&mut s, "paint.setStroke", json!({"none": true})); + painted(&mut s, 120.0, "#0000ff", "#000000", 7.0); + select(&mut s, &[bare]); + assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (Paint::None, 7.0)); +} + +#[test] +fn a_selected_gradient_loads_without_its_place_on_the_object() { + let mut s = session(); + let src = rect(&mut s, 20.0); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + run(&mut s, "paint.setFill", json!({"gradient": {"stops": stops, "start": [20, 40], "end": [50, 40]}})); + painted(&mut s, 120.0, "#0000ff", "#000000", 2.0); + select(&mut s, &[src]); + assert!(matches!(s.paint.fill, Paint::Gradient(_)), "{:?}", s.paint.fill); + run(&mut s, "select.none", json!({})); + let id = rect(&mut s, 220.0); + assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Gradient(_))); +} + +#[test] +fn selecting_leaves_the_stroke_panels_options_for_new_art_alone() { + let mut s = session(); + let plain = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0); + run(&mut s, "select.none", json!({})); + run(&mut s, "stroke.set", json!({"cap": "round", "dash": [4, 2]})); + select(&mut s, &[plain]); + run(&mut s, "select.none", json!({})); + let id = rect(&mut s, 120.0); + let st = node(&s, id).appearance.stroke().unwrap().clone(); + assert_eq!((st.paint.clone(), st.width), (node(&s, plain).appearance.stroke_paint(), 5.0)); + assert_eq!((st.cap, st.dash.map(|d| d.pattern)), (LineCap::Round, Some(vec![4.0, 2.0]))); +} + +#[test] +fn a_sampled_paint_stays_until_the_selection_changes() { + let mut s = session(); + let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0); + let sampled = Paint::solid(vectorcraft_color::Color::WHITE); + s.paint.fill = sampled.clone(); + run(&mut s, "transparency.set", json!({"opacity": 50})); + assert_eq!(s.paint.fill, sampled, "not a selection command"); + select(&mut s, &[a]); + assert_eq!(s.paint.fill, sampled, "the same selection again"); + run(&mut s, "select.none", json!({})); + assert_eq!(s.paint.fill, sampled, "nothing selected shows nothing to load"); + select(&mut s, &[a]); + assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint(), "selected anew"); +} + +#[test] +fn targeting_a_layer_loads_the_paints_of_its_contents() { + let mut s = session(); + let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0); + let layer = s.doc().unwrap().doc.layers[0].id; + run(&mut s, "select.none", json!({})); + run(&mut s, "paint.setFill", json!({"color": "#0000ff"})); + run(&mut s, "layer.target", json!({"id": layer.0})); + assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint()); +} + #[test] fn the_option_round_trips_with_the_preferences() { let mut s = session(); diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index 4de6abd39..b096922e9 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -1024,6 +1024,19 @@ fn lasso_picks_gradient_mesh_points() { /// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are /// deselected and the others selected, the rest of the selection staying as it was. +#[test] +fn a_selection_tool_click_gives_new_art_the_objects_fill() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + rect(&mut s, 200.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + gesture(&mut s, "selection", &[(125.0, 125.0), (125.0, 125.0)], Mods::default()); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.paint.fill, s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint()); +} + #[test] fn selection_shift_marquee_toggles_objects() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index f2b9cd0f3..9b08d672f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4126,8 +4126,10 @@ New objects take the fill and stroke proxies (`paint.setFill` / `paint.setStroke selected) on top of a template. With nothing selected, `stroke.set` / `stroke.setAdvanced` (cap, join, dashes, arrowheads, profile…) and `graphicStyle.apply` (every fill, stroke and effect, opacity, blend mode and the link to the style; `add: true` stacks it) set that template instead of editing art. With the Appearance panel's New Art Has Basic -Appearance off (`appearance.setNewArtBasic {on: false}`, the preference `newArtBasic`) new art takes the whole -appearance of the last selected object. `appearance.newArt` reports what the next object gets; `paint.default` +Appearance on (the default), a `select.*` command or `layer.target` that changes the selection loads the proxies and +the weight from it: only a paint or weight the selected objects share, and art without paint (a placed image) changes +nothing. With the option off (`appearance.setNewArtBasic {on: false}`, the preference `newArtBasic`) new art takes the +whole appearance of the last selected object. `appearance.newArt` reports what the next object gets; `paint.default` resets it. `appearance.setNewArtBasic` rejects an `on` value that is not a boolean. Omitting `on` toggles the current preference.